v3 : troisième relecture — une coquille n'inverse plus un sens ; retour à la version précédente

- 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 <noreply@anthropic.com>
This commit is contained in:
Yvv
2026-10-21 05:21:08 +02:00
co-authored by Claude Opus 5.5
parent ac9c904e7e
commit 9203fd2501
10 changed files with 331 additions and 47 deletions
+245 -17
View File
@@ -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<char>, Vec<char>) = (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<usize> = (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);
}
+4 -1
View File
@@ -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<Json<serde_json::Value>> {
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 {
+21
View File
@@ -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();