From 9203fd25015fbf256295be2063dc462774d20e80 Mon Sep 17 00:00:00 2001 From: Yvv Date: Wed, 21 Oct 2026 05:21:08 +0200 Subject: [PATCH] =?UTF-8?q?v3=20:=20troisi=C3=A8me=20relecture=20=E2=80=94?= =?UTF-8?q?=20une=20coquille=20n'inverse=20plus=20un=20sens=20;=20retour?= =?UTF-8?q?=20=C3=A0=20la=20version=20pr=C3=A9c=C3=A9dente?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Mot mal tapé = un seul glissement (une lettre ajoutée, retirée, ou deux voisines inversées), jamais au début du mot (préfixes : valide/invalide, limitée/illimitée), jamais une lettre remplacée (deux/dix, majorité/minorité), jamais sur un mot de négation ou de quantité, mots de 4 lettres au moins. - Parenthèses, crochets, « / » et tout ce qui est entre accents graves comptent comme le sens (ceil(N^(1/5)) → ceil(N^1/5) n'est plus une coquille). - Caractères invisibles (inversion du sens d'écriture, largeur nulle, BOM) refusés dans les textes, titres, traductions et motifs de signalement. - Déploiement : la version en service est gardée (image « previous ») ; un témoin marque le déploiement en cours ; « retablir » relance la maquette au premier déploiement, la version précédente ensuite ; la liste des conteneurs retirés n'est plus vidée par un pipeline annulé. - Tests : 59 Rust (dont 14 scénarios d'API, avec deux → dix sur E2 en vigueur et l'inversion bidi), e2e 12 / 12. Co-Authored-By: Claude Opus 5.5 --- .woodpecker/app.yml | 49 ++-- HANDOFF.md | 12 +- crates/ld-engine/src/edit.rs | 262 ++++++++++++++++-- crates/ld-server/src/proposals.rs | 5 +- crates/ld-server/tests/api.rs | 21 ++ docs/adr/0003-corriger-n-est-pas-voter.md | 12 +- docs/chantiers/acte-certification/CHANTIER.md | 14 +- web/src/lib/i18n/en.ts | 1 + web/src/lib/i18n/es.ts | 1 + web/src/lib/i18n/fr.ts | 1 + 10 files changed, 331 insertions(+), 47 deletions(-) diff --git a/.woodpecker/app.yml b/.woodpecker/app.yml index f4346bd..e54b189 100644 --- a/.woodpecker/app.yml +++ b/.woodpecker/app.yml @@ -34,6 +34,8 @@ 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)" @@ -99,6 +101,8 @@ steps: - /opt/libredecision-app:/opt/libredecision-app commands: - DOMAIN=decision.librodrome.org + # 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 - cp docker/app-compose.yml /opt/libredecision-app/docker-compose.yml # Tout conteneur, même arrêté, qui revendique encore la racine (la maquette avant son @@ -106,11 +110,11 @@ steps: # « restart: always » : sinon un redémarrage de Docker le ramènerait, et fabio lui # donnerait /api/* (route la plus longue). Rien n'est supprimé ; la maquette est # recréée par son propre workflow, sous son nouveau domaine. - # Ceux qui tournaient sont notés dans /opt/libredecision-app/retires : si la suite - # échoue, l'étape « retablir » les relance (et seulement eux). + # Ceux qui tournaient sont ajoutés à /opt/libredecision-app/retires (jamais vidé ici : + # un pipeline annulé ne perd pas sa liste) ; si la suite échoue, « retablir » les relance. - | RETIRES=/opt/libredecision-app/retires - : > "$RETIRES" + touch "$RETIRES" for TAG in "urlprefix-$DOMAIN/*" "urlprefix-$DOMAIN/api/*"; do for C in $(docker ps -aq --filter "label=SERVICE_3000_TAGS=$TAG") $(docker ps -aq --filter "label=SERVICE_8002_TAGS=$TAG"); do NAME=$(docker inspect --format '{{.Name}}' "$C") @@ -182,6 +186,7 @@ steps: *'"ok":true'*"\"build\":\"$BUILD\""*|*"\"build\":\"$BUILD\""*'"ok":true'*) echo "PASS: $BUILD repond sur $TARGET" : > /opt/libredecision-app/retires + rm -f /opt/libredecision-app/deploying exit 0 ;; esac i=$((i+1)) @@ -190,9 +195,10 @@ steps: echo "FAIL: $BUILD ne repond pas apres 10 minutes" exit 1 - # Échec après le retrait de la racine : le produit s'arrête, ce qu'il avait retiré - # repart (restart: always rendu). Échec avant (construction, tests) : rien à faire, - # la liste est vide depuis le dernier déploiement réussi. + # Échec avant le déploiement (construction, tests, SBOM) : rien n'a bougé, rien à faire. + # Échec après, au premier déploiement : le produit s'arrête et ce qu'il avait retiré de la + # racine repart (restart: always rendu). Échec après, plus tard : la version précédente du + # produit (image « previous ») est relancée. # NOTE: volumes + pas de from_secret : compatible - name: retablir image: docker:27-cli @@ -205,17 +211,26 @@ steps: - /opt/libredecision-app:/opt/libredecision-app commands: - | - RETIRES=/opt/libredecision-app/retires - if [ ! -s "$RETIRES" ]; then echo "Rien a retablir"; exit 0; fi - # Hors de « restart: always » aussi, sinon un redémarrage de Docker le ramènerait - # à côté de ce qui revient ; le prochain déploiement le recrée (--force-recreate). - docker update --restart=no lpk-libredecision-app >/dev/null 2>&1 || true - docker stop lpk-libredecision-app >/dev/null 2>&1 || true - for C in $(cat "$RETIRES"); do - echo "Retour de $(docker inspect --format '{{.Name}}' "$C" 2>/dev/null || echo "$C")" - docker update --restart=always "$C" >/dev/null && docker start "$C" >/dev/null || echo " echec pour $C" - done - : > "$RETIRES" + D=/opt/libredecision-app + if [ ! -e "$D/deploying" ]; then echo "Echec avant le deploiement : rien a retablir"; exit 0; fi + if [ -s "$D/retires" ]; then + # Hors de « restart: always » aussi, sinon un redémarrage de Docker le ramènerait + # à côté de ce qui revient ; le prochain déploiement le recrée (--force-recreate). + docker update --restart=no lpk-libredecision-app >/dev/null 2>&1 || true + docker stop lpk-libredecision-app >/dev/null 2>&1 || true + for C in $(cat "$D/retires"); do + echo "Retour de $(docker inspect --format '{{.Name}}' "$C" 2>/dev/null || echo "$C")" + docker update --restart=always "$C" >/dev/null && docker start "$C" >/dev/null || echo " echec pour $C" + done + : > "$D/retires" + elif docker image inspect libredecision-app:previous >/dev/null 2>&1; then + echo "Retour a la version precedente du produit" + docker tag libredecision-app:previous libredecision-app:latest + cd "$D" && docker compose up -d --force-recreate + else + echo "Aucune version precedente a relancer" + fi + rm -f "$D/deploying" when: - status: failure diff --git a/HANDOFF.md b/HANDOFF.md index 938d0a6..a018741 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`, 57 tests dont 14 scénarios d'API et la +- Boucle : Rust (fmt, clippy `-D warnings`, 59 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 @@ -15,11 +15,15 @@ - 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). -- Relecture indépendante avant mise en ligne (`1f9c241 : ok`) ; ses points « à corriger - ensuite » ont été traités avant de livrer : ordre des workflows, anciens conteneurs en +- 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 `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. + 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é. ## Ce qui attend Yvv diff --git a/crates/ld-engine/src/edit.rs b/crates/ld-engine/src/edit.rs index f7c230a..724aff1 100644 --- a/crates/ld-engine/src/edit.rs +++ b/crates/ld-engine/src/edit.rs @@ -4,9 +4,11 @@ //! tells two kinds of corrections apart: //! //! - **typographic**: accents, case, punctuation, spacing, emphasis, or at most -//! two misspelt words fixed (edit distance ≤ 2, no negation touched) — and -//! never a figure, a sign, a separator inside a number or a relation -//! (`≥`, `+`, `%`…). The votes stay; the correction is shown with its diff. +//! 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. //! - **substantive**: anything else. It opens a new revision and every vote //! cast on the previous one waits for its author to reconfirm it. //! @@ -32,8 +34,154 @@ 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. +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", +]; + const MAX_TYPO_WORDS: usize = 2; -const MAX_TYPO_DISTANCE: usize = 2; +/// Shorter words have too many neighbours one letter away ("can" / "cant"). +const MIN_TYPO_WORD: usize = 4; /// Lowercase, strip diacritics. fn fold(s: &str) -> String { @@ -49,14 +197,56 @@ fn skeleton(s: &str) -> String { fold(s).chars().filter(|c| c.is_alphanumeric()).collect() } -/// Symbols that carry a value or a relation, wherever they stand. -const RELATIONS: &[char] = - &['+', '±', '×', '÷', '^', '<', '>', '=', '≤', '≥', '≠', '≈', '%', '‰', '€', '$', '£', '∞', '√']; -/// Signs and fraction bars: meaningful next to a figure ("-5", "1/5", "5-10"). -const SIGNS: &[char] = &['-', '−', '–', '/']; +/// 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}', '\'', '’']; +/// Invisible format characters (zero-width, bidirectional overrides and +/// isolates, byte order mark): they can make "12" display as "21" ("Trojan +/// Source"). A text of reference never needs them; they are refused. +pub fn has_hidden_controls(s: &str) -> bool { + s.chars().any( + |c| matches!(c as u32, 0x200B..=0x200F | 0x202A..=0x202E | 0x2060..=0x2064 | 0x2066..=0x2069 | 0x061C | 0xFEFF), + ) +} + +/// 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é"). +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] { + return false; + } + 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| { + let mut l = long.clone(); + l.remove(i); + &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]] + } + _ => 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 @@ -87,7 +277,7 @@ pub fn classify(old: &str, new: &str) -> EditKind { 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) { + if figures(old) != figures(new) || code_spans(old) != code_spans(new) { return EditKind::Substantive; } if skeleton(old) == skeleton(new) { @@ -104,12 +294,10 @@ pub fn classify(old: &str, new: &str) -> EditKind { } changed += 1; let touches_meaning = changed > MAX_TYPO_WORDS - || x.chars().any(|c| c.is_ascii_digit()) - || y.chars().any(|c| c.is_ascii_digit()) - || NEGATIONS.contains(&x.as_str()) - || NEGATIONS.contains(&y.as_str()) - || x.chars().count().min(y.chars().count()) < 3 - || levenshtein(x, y) > MAX_TYPO_DISTANCE; + || x.chars().any(char::is_numeric) + || y.chars().any(char::is_numeric) + || [x, y].iter().any(|w| NEGATIONS.contains(&w.as_str()) || QUANTITIES.contains(&w.as_str())) + || !one_slip(x, y); if touches_meaning { return EditKind::Substantive; } @@ -251,14 +439,54 @@ mod tests { // 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("1. premier point", "1) premier point"), 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); } + #[test] + fn a_word_turned_around_is_never_a_typo() { + // Third independent review, 2026-10-21: one or two letters that invert a meaning. + for (old, new) in [ + ("ces deux présents engagements", "ces dix présents engagements"), + ("Nouveau compte valide si", "Nouveau compte invalide si"), + ("100 certifications possibles", "100 certifications impossibles"), + ("à la majorité des voix", "à la minorité des voix"), + ("un minimum de garanties", "un maximum de garanties"), + ("les membres inclus", "les membres exclus"), + ("une personne majeure", "une personne mineure"), + ("une durée limitée", "une durée illimitée"), + ("un mandat révocable", "un mandat irrévocable"), + ("des comptes indépendants", "des comptes dépendants"), + ("pendant six mois", "pendant dix mois"), + ("sept certifications", "cent certifications"), + ("seize jours", "treize jours"), + ("five members", "nine members"), + ("you can certify", "you cant certify"), + ("Y = ceil(N^(1/5))", "Y = ceil(N^1/5)"), + ("M(n)/N(n)", "M(n)N(n)"), + ("DU(n) - c", "DU(n) / c"), + ("la formule `a - b`", "la formule `a * b`"), + ] { + assert_eq!(classify(old, new), EditKind::Substantive, "{old} → {new}"); + } + } + + #[test] + fn hidden_controls_are_spotted() { + assert!(has_hidden_controls("**\u{202e}12\u{202c} mois**")); + assert!(has_hidden_controls("5\u{200b}0")); + assert!(!has_hidden_controls("Je m’engage — « à 50 % ».")); + } + #[test] fn misspelt_words() { assert_eq!(classify("un certifieurs existant", "un certifieur existant"), EditKind::Typographic); 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. + assert_eq!(classify("une certifixation", "une certification"), EditKind::Substantive); // « personne » is also a negation (ne … personne): conservative. assert_eq!(classify("la persone", "la personne"), EditKind::Substantive); } diff --git a/crates/ld-server/src/proposals.rs b/crates/ld-server/src/proposals.rs index e799a5b..4a13c0e 100644 --- a/crates/ld-server/src/proposals.rs +++ b/crates/ld-server/src/proposals.rs @@ -59,6 +59,9 @@ async fn guarded(state: &AppState, user: &IdentityRow, proposal_id: &str) -> App } fn check_text(title: &str, body: &str, needs_body: bool) -> AppResult<()> { + if edit::has_hidden_controls(title) || edit::has_hidden_controls(body) { + return Err(AppError::Invalid("hidden_characters")); + } if title.trim().is_empty() || title.chars().count() > MAX_TITLE { return Err(AppError::Invalid("title")); } @@ -647,7 +650,7 @@ pub async fn signal( ) -> AppResult> { let (p, st) = guarded(&state, &user, &id).await?; let reason = b.reason.trim(); - if reason.chars().count() < MIN_REASON || reason.chars().count() > MAX_REASON { + if reason.chars().count() < MIN_REASON || reason.chars().count() > MAX_REASON || edit::has_hidden_controls(reason) { return Err(AppError::Invalid("reason")); } if b.target_id == id { diff --git a/crates/ld-server/tests/api.rs b/crates/ld-server/tests/api.rs index 50eb410..21ab922 100644 --- a/crates/ld-server/tests/api.rs +++ b/crates/ld-server/tests/api.rs @@ -230,6 +230,27 @@ async fn editing_is_not_voting() { .await; assert_eq!(s, StatusCode::CONFLICT, "{v}"); assert_eq!(v["error"], "substantive_not_author"); + // One word turned around in a text in force, and an invisible bidi override: refused. + let e2 = unit(&doc, "E2")["inForce"].clone(); + let e2_id = e2["id"].as_str().expect("id").to_owned(); + let body = e2["body"].as_str().expect("body").to_owned(); + assert!(body.contains("ces deux présents")); + let (s, v) = b + .call( + "PATCH", + &format!("/api/proposals/{e2_id}"), + Some(json!({ "title": e2["title"], "body": body.replacen("deux", "dix", 1) })), + ) + .await; + assert_eq!((s, v["error"].as_str()), (StatusCode::CONFLICT, Some("substantive_in_force"))); + let (s, v) = b + .call( + "PATCH", + &format!("/api/proposals/{e2_id}"), + Some(json!({ "title": e2["title"], "body": format!("\u{202e}{body}") })), + ) + .await; + assert_eq!((s, v["error"].as_str()), (StatusCode::BAD_REQUEST, Some("hidden_characters"))); // 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 df73201..ea80dd4 100644 --- a/docs/adr/0003-corriger-n-est-pas-voter.md +++ b/docs/adr/0003-corriger-n-est-pas-voter.md @@ -4,11 +4,15 @@ 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 ou une relation - change (« 50 » → « 5,0 », « 182.625 » → « 182 625 », « ≥ » → « ≤ », « + » → « - ») ; +- **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) ; - **coquille** si le squelette (sans accents, casse, ponctuation, espaces) est identique, ou si - au plus 2 mots diffèrent de 2 lettres au plus, sans chiffre ni mot de négation (ne, pas, - jamais, personne…) ; elle s'applique sans vote, garde les votes et les traductions ; + 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, + 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. diff --git a/docs/chantiers/acte-certification/CHANTIER.md b/docs/chantiers/acte-certification/CHANTIER.md index 07d5097..4bfac66 100644 --- a/docs/chantiers/acte-certification/CHANTIER.md +++ b/docs/chantiers/acte-certification/CHANTIER.md @@ -76,13 +76,19 @@ 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 à 2 lettres près) | 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) | 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 : un chiffre, un signe, un séparateur dans un nombre ou une relation -changés (« 50 » → « 5,0 », « 182.625 » → « 182 625 », « ≥ » → « ≤ », « + » → « - », « % ») -ou un mot de négation touché (ne, pas, jamais, personne…) rendent la modification « de fond ». Moteur : `crates/ld-engine/src/edit.rs`. +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`. ### Deux articles qui se contredisent diff --git a/web/src/lib/i18n/en.ts b/web/src/lib/i18n/en.ts index e4f740b..eb97fd4 100644 --- a/web/src/lib/i18n/en.ts +++ b/web/src/lib/i18n/en.ts @@ -311,6 +311,7 @@ export const en: Messages = { 'error.g1_not_configured': "Ğ1 sign-in isn't active yet.", '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.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 71625f2..7a18056 100644 --- a/web/src/lib/i18n/es.ts +++ b/web/src/lib/i18n/es.ts @@ -311,6 +311,7 @@ export const es: Messages = { 'error.g1_not_configured': 'La conexión Ğ1 aún no está activa.', '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.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 5f9c356..888a325 100644 --- a/web/src/lib/i18n/fr.ts +++ b/web/src/lib/i18n/fr.ts @@ -314,6 +314,7 @@ export const fr = { 'error.g1_not_configured': "La connexion Ğ1 n'est pas encore active.", '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.wot_members_only': 'Ici, seuls les membres de la toile de confiance Ğ1 votent.', 'error.retry': 'Réessayer',