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
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
- |
+1 -1
View File
@@ -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
+34 -12
View File
@@ -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<char> = s.nfc().collect();
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()
.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}");
}
+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 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.
+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
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
@@ -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