v3 : cinquième relecture — rien de ce qui touche un chiffre n'est une coquille

- Signature : tout caractère qui n'est pas une lettre latine et touche un
  chiffre compte, espaces et ponctuation comprises (« 5, 0 », « 1:5 »,
  « 1…5 ») ; un tiret compte devant ou après un chiffre à travers les espaces
  (« - 5 ») ; seules quatre espaces sont libres (ordinaire, insécable, fine
  insécable, saut de ligne).
- Négations complétées (guère, point, nothing, nobody, nadie, jamás…).
- Adoption : le texte qui entre en vigueur est celui qui a été compté — une
  révision arrivée pendant le passage l'arrête.
- Déploiement : « previous » est gardé avant le témoin de déploiement.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Yvv
2026-10-21 06:23:15 +02:00
co-authored by Claude Opus 5.5
parent 1eac6f856a
commit 6c0754a5c3
6 changed files with 63 additions and 21 deletions
+5 -5
View File
@@ -99,6 +99,11 @@ steps:
- /opt/libredecision-app:/opt/libredecision-app - /opt/libredecision-app:/opt/libredecision-app
commands: commands:
- DOMAIN=decision.librodrome.org - 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. # Témoin : à partir d'ici, un échec appelle « retablir ». Le healthcheck réussi l'efface.
- touch /opt/libredecision-app/deploying - touch /opt/libredecision-app/deploying
- echo "APP_DOMAIN=$DOMAIN" > /opt/libredecision-app/.env - echo "APP_DOMAIN=$DOMAIN" > /opt/libredecision-app/.env
@@ -125,11 +130,6 @@ steps:
docker stop "$C" >/dev/null docker stop "$C" >/dev/null
done done
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. # Arrêter avant le challenge ACME : la route hôte /* masquerait le webroot de sonic-acme-1.
- cd /opt/libredecision-app && docker compose stop - cd /opt/libredecision-app && docker compose stop
- | - |
+1 -1
View File
@@ -16,7 +16,7 @@
racine tout conteneur qui la revendique), puis `.woodpecker/maquette.yml` (la maquette part 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). 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 : - 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 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 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 règle est désormais une liste blanche mesurée contre le texte voté (voir CHANTIER.md). Les
+34 -12
View File
@@ -29,9 +29,9 @@ pub enum EditKind {
const NEGATIONS: &[&str] = &[ const NEGATIONS: &[&str] = &[
// fr // fr
"ne", "n", "pas", "jamais", "non", "sans", "aucun", "aucune", "ni", "plus", "rien", "nul", "nulle", "personne", "ne", "n", "pas", "jamais", "non", "sans", "aucun", "aucune", "ni", "plus", "rien", "nul", "nulle", "personne",
// en "guere", "point", // en
"not", "no", "never", "without", "none", "nor", // es "not", "no", "never", "without", "none", "nor", "nothing", "nobody", "nowhere", "neither", // es
"nunca", "sin", "ningun", "ninguna", "ninguno", "tampoco", "nada", "nunca", "sin", "ningun", "ninguna", "ninguno", "tampoco", "nada", "nadie", "jamas", "ni",
]; ];
/// Words that carry a quantity, a scope or a relation (folded); changing one is /// 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. /// Ordinary punctuation, free to change in a typo fix — except where noted below.
const PUNCTUATION: &[char] = const PUNCTUATION: &[char] =
&['.', ',', ';', ':', '!', '?', '\'', '’', '‘', '"', '“', '”', '«', '»', '…', '‐', '-', '–', '—', '*', '_']; &['.', ',', ';', ':', '!', '?', '\'', '’', '‘', '"', '“', '”', '«', '»', '…', '‐', '-', '–', '—', '*', '_'];
/// Separators that become part of a number between two figures ("10 000", "10,000", "182.625"). /// The only spaces free to change; any other (hair, thin, ogham…) is kept as it is.
const NUMBER_SEPARATORS: &[char] = &['.', ',', '\'', '’']; const FREE_SPACES: &[char] = &[' ', '\u{a0}', '\u{202f}', '\n', '\r', '\t'];
/// Dashes that become a sign next to a figure ("-5", "5-10"). /// Dashes become a sign when a figure follows or precedes, even across spaces ("- 5").
const DASHES: &[char] = &['‐', '-', '–', '—']; const DASHES: &[char] = &['‐', '-', '–', '—'];
/// Invisible format characters (zero-width, bidirectional overrides and /// 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, /// 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, /// 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. /// 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 { fn signature(s: &str) -> String {
let cs: Vec<char> = s.nfc().collect(); let cs: Vec<char> = s.nfc().collect();
let is_figure = |i: Option<usize>| i.and_then(|i| cs.get(i)).is_some_and(|c| c.is_numeric()); let is_figure = |i: Option<usize>| 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() cs.iter()
.enumerate() .enumerate()
.filter(|&(i, &c)| { .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) { if is_latin_letter(c) {
false false
} else if c.is_whitespace() { } else if touches_figure {
before && after
} else if NUMBER_SEPARATORS.contains(&c) && before && after {
true true
} else if FREE_SPACES.contains(&c) {
false
} else if DASHES.contains(&c) { } else if DASHES.contains(&c) {
before || after across(i, -1) || across(i, 1)
} else { } else {
!PUNCTUATION.contains(&c) !PUNCTUATION.contains(&c)
} }
@@ -412,6 +421,19 @@ mod tests {
("je certifierai", "je certifierais"), ("je certifierai", "je certifierais"),
("será válido", "sería válido"), ("será válido", "sería válido"),
("they must certify", "they mustn certify"), ("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}"); assert_eq!(classify(old, new), EditKind::Substantive, "{old} → {new}");
} }
+16
View File
@@ -78,6 +78,22 @@ pub async fn run(state: &AppState, doc_id: &str) -> AppResult<Vec<Entered>> {
let params = us.map(|u| compose::params_for(u, p.kind(), st.doc.vote_floor)); let params = us.map(|u| compose::params_for(u, p.kind(), st.doc.vote_floor));
let now = util::now(); let now = util::now();
let mut tx = state.db.begin().await?; 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() { let kind = match p.kind() {
ProposalKind::Text | ProposalKind::Remove => { ProposalKind::Text | ProposalKind::Remove => {
// The displaced version goes back among the proposals, with its votes. // The displaced version goes back among the proposals, with its votes.
+3 -1
View File
@@ -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 - **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, 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 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 - **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 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 ajoutée, retirée ou deux voisines inversées, jamais aux deux premières lettres (préfixes) ni
@@ -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, écriture), signes, relations, parenthèses, sosies (« ‚ » pour une virgule, « ⁄ » pour une barre,
formes pleine chasse), marques superposées (rayure, barre), autres alphabets (cyrillique, 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, 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 », liens, courriels), **tout ce qui touche un chiffre**, espaces et ponctuation comprises (« 50 » →
« 182.625 » → « 182 625 », « -5 »). Pour les mots : au plus deux mots, chacun **un seul « 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 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 (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 une lettre remplacée (« deux » → « dix »), jamais un mot de négation, de quantité ou de position