diff --git a/.woodpecker/app.yml b/.woodpecker/app.yml index 256e9e3..5b98b0c 100644 --- a/.woodpecker/app.yml +++ b/.woodpecker/app.yml @@ -99,6 +99,11 @@ steps: - /opt/libredecision-app:/opt/libredecision-app commands: - 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. + - | + 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 # 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 @@ -125,11 +130,6 @@ 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 ddcf49f..9198ef4 100644 --- a/HANDOFF.md +++ b/HANDOFF.md @@ -16,7 +16,7 @@ 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`. Les deux dernières ont + 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 diff --git a/crates/ld-engine/src/edit.rs b/crates/ld-engine/src/edit.rs index 7efa075..6d1b58f 100644 --- a/crates/ld-engine/src/edit.rs +++ b/crates/ld-engine/src/edit.rs @@ -29,9 +29,9 @@ pub enum EditKind { const NEGATIONS: &[&str] = &[ // fr "ne", "n", "pas", "jamais", "non", "sans", "aucun", "aucune", "ni", "plus", "rien", "nul", "nulle", "personne", - // en - "not", "no", "never", "without", "none", "nor", // es - "nunca", "sin", "ningun", "ninguna", "ninguno", "tampoco", "nada", + "guere", "point", // en + "not", "no", "never", "without", "none", "nor", "nothing", "nobody", "nowhere", "neither", // es + "nunca", "sin", "ningun", "ninguna", "ninguno", "tampoco", "nada", "nadie", "jamas", "ni", ]; /// Words that carry a quantity, a scope or a relation (folded); changing one is @@ -90,9 +90,9 @@ fn is_latin_letter(c: char) -> bool { /// 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"). +/// 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 @@ -109,22 +109,31 @@ pub fn has_hidden_controls(s: &str) -> bool { /// 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. +/// 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 { 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 (before, after) = (is_figure(i.checked_sub(1)), is_figure(Some(i + 1))); + let touches_figure = 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 { + } else if touches_figure { true + } else if FREE_SPACES.contains(&c) { + false } else if DASHES.contains(&c) { - before || after + across(i, -1) || across(i, 1) } else { !PUNCTUATION.contains(&c) } @@ -412,6 +421,19 @@ mod tests { ("je certifierai", "je certifierais"), ("será válido", "sería válido"), ("they must certify", "they mustn certify"), + // Fifth independent review: anything touching a figure, special spaces, signs across spaces. + ("au moins 50 voix", "au moins 5,\u{202f}0 voix"), + ("**12 mois**", "**1,\u{202f}2 mois**"), + ("100 certifications", "10,\u{200a}0 certifications"), + ("80%", "8,\u{200a}0%"), + ("15 jours", "1:5 jours"), + ("15 jours", "1…5 jours"), + ("15 jours", "1*5 jours"), + ("15 jours", "1;5 jours"), + ("15 jours", "1‘5 jours"), + ("un écart de 5", "un écart de -\u{200a}5"), + ("un écart de 5", "un écart de - 5"), + ("un écart de 5", "un écart de \u{1680}5"), ] { assert_eq!(classify(old, new), EditKind::Substantive, "{old} → {new}"); } diff --git a/crates/ld-server/src/adoption.rs b/crates/ld-server/src/adoption.rs index b61c7ca..4e4eebc 100644 --- a/crates/ld-server/src/adoption.rs +++ b/crates/ld-server/src/adoption.rs @@ -78,6 +78,22 @@ pub async fn run(state: &AppState, doc_id: &str) -> AppResult> { let params = us.map(|u| compose::params_for(u, p.kind(), st.doc.vote_floor)); let now = util::now(); let mut tx = state.db.begin().await?; + // The text that enters is the text that was counted: a revision that landed + // since the state was read stops it (the next pass will judge it again). + let unchanged = sqlx::query( + "UPDATE proposals SET updated_at = updated_at + WHERE id = ? AND revision = ? AND title = ? AND body = ? AND status = ?", + ) + .bind(&p.id) + .bind(p.revision) + .bind(&p.title) + .bind(&p.body) + .bind(&p.status) + .execute(&mut *tx) + .await?; + if unchanged.rows_affected() != 1 { + continue; + } let kind = match p.kind() { ProposalKind::Text | ProposalKind::Remove => { // The displaced version goes back among the proposals, with its votes. diff --git a/docs/adr/0003-corriger-n-est-pas-voter.md b/docs/adr/0003-corriger-n-est-pas-voter.md index d187efc..6cb787a 100644 --- a/docs/adr/0003-corriger-n-est-pas-voter.md +++ b/docs/adr/0003-corriger-n-est-pas-voter.md @@ -9,7 +9,9 @@ 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 ; + 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 diff --git a/docs/chantiers/acte-certification/CHANTIER.md b/docs/chantiers/acte-certification/CHANTIER.md index 24aea7a..aa33296 100644 --- a/docs/chantiers/acte-certification/CHANTIER.md +++ b/docs/chantiers/acte-certification/CHANTIER.md @@ -86,8 +86,10 @@ ponctuation ordinaire. Tout le reste compte comme le sens et ne bouge pas : chif é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 +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