v3 : quatrième relecture — coquille en liste blanche, mesurée contre le texte voté

- Une coquille ne touche que des lettres latines, des espaces et la ponctuation
  ordinaire ; tout le reste est figé : chiffres de toute écriture, signes,
  relations, parenthèses, sosies (U+201A, U+2044, pleine chasse), marques
  superposées (U+0336, U+0338), autres alphabets, rayure Markdown, contenu entre
  accents graves, séparateurs et tirets collés à un chiffre.
- Adresses (domaines, liens, courriels) comparées à l'identique.
- Glissement de clavier au cœur du mot seulement (ni les deux premières ni les
  deux dernières lettres : préfixes, nombre, genre, temps, mode) ; mots de
  position ajoutés (dessus/dessous, avant/après…).
- Chaque correction est mesurée contre le texte voté (dernière révision initiale
  ou de fond) : les glissements ne s'additionnent plus ; le texte soumis d'un
  brouillon devient sa révision initiale.
- Déploiement : « previous » = l'image du conteneur en service, pas la dernière
  construite ; migrations inconnues tolérées pour qu'une image précédente démarre.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Yvv
2026-10-21 05:58:36 +02:00
co-authored by Claude Opus 5.5
parent 9203fd2501
commit 1eac6f856a
8 changed files with 236 additions and 206 deletions
+5 -2
View File
@@ -34,8 +34,6 @@ steps:
- /var/run/docker.sock:/var/run/docker.sock - /var/run/docker.sock:/var/run/docker.sock
commands: commands:
- BUILD=$(echo "$CI_COMMIT_SHA" | cut -c1-8) - 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 . - docker build -t libredecision-app:latest -f docker/app.Dockerfile --target production --build-arg LD_BUILD=$BUILD .
- echo "Image app construite ($BUILD)" - echo "Image app construite ($BUILD)"
@@ -127,6 +125,11 @@ 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
- | - |
+6 -4
View File
@@ -15,10 +15,12 @@
- Déploiement : `.woodpecker/app.yml` d'abord (le produit à la racine, qui retire de la - 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 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).
- Trois relectures indépendantes avant mise en ligne (`1f9c241 : ok`, `94907af : ok`, - Relectures indépendantes avant mise en ligne, chacune traitée avant de livrer : `1f9c241 :
`ac9c904 : à reprendre`), chacune traitée avant de livrer. La troisième a montré qu'une ok`, `94907af : ok`, `ac9c904 : à reprendre`, `9203fd2 : à reprendre`. Les deux dernières ont
« coquille » pouvait encore inverser un sens (« valide » → « invalide », « deux » → « dix », montré qu'une « coquille » (sans vote, publiée) pouvait encore changer un sens — mots
caractères invisibles) : la règle a été resserrée (voir CHANTIER.md). Les autres points : ordre des workflows, anciens conteneurs en 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
autres points : ordre des workflows, anciens conteneurs en
`restart: always`, vote réservé aux membres de la toile dans Monnaie libre (une personne, `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, 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,
+127 -182
View File
@@ -34,154 +34,37 @@ const NEGATIONS: &[&str] = &[
"nunca", "sin", "ningun", "ninguna", "ninguno", "tampoco", "nada", "nunca", "sin", "ningun", "ninguna", "ninguno", "tampoco", "nada",
]; ];
/// Words that carry a quantity or a scope (folded); changing one is never a typo. /// Words that carry a quantity, a scope or a relation (folded); changing one is
/// never a typo.
#[rustfmt::skip]
const QUANTITIES: &[&str] = &[ const QUANTITIES: &[&str] = &[
// fr // fr
"un", "un", "une", "deux", "trois", "quatre", "cinq", "six", "sept", "huit", "neuf", "dix", "onze", "douze", "treize",
"une", "quatorze", "quinze", "seize", "vingt", "trente", "quarante", "cinquante", "soixante", "septante", "huitante",
"deux", "octante", "nonante", "cent", "cents", "mille", "million", "millions", "milliard", "milliards", "premier",
"trois", "premiere", "premiers", "premieres", "second", "seconde", "deuxieme", "troisieme", "dernier", "derniere", "double",
"quatre", "triple", "moitie", "tiers", "quart", "moins", "minimum", "maximum", "majorite", "minorite", "unanimite", "tous",
"cinq", "toutes", "tout", "toute", "chaque", "seul", "seule", "seuls", "seules", "dessus", "dessous", "superieur",
"six", "superieure", "inferieur", "inferieure", "avant", "apres", "sous", "entre", "hors", "sauf", "inclus", "exclus",
"sept", // en
"huit", "one", "two", "three", "four", "five", "six", "seven", "eight", "nine", "ten", "eleven", "twelve", "twenty",
"neuf", "thirty", "forty", "fifty", "sixty", "hundred", "thousand", "million", "first", "second", "third", "last", "half",
"dix", "twice", "less", "more", "least", "most", "majority", "minority", "all", "every", "each", "only", "above", "below",
"onze", "over", "under", "before", "after", "within", "except",
"douze", // es
"treize", "uno", "dos", "tres", "cuatro", "cinco", "seis", "siete", "ocho", "nueve", "diez", "once", "doce", "veinte",
"quatorze", "treinta", "cuarenta", "cincuenta", "cien", "ciento", "mil", "millon", "primero", "primera", "segundo", "segunda",
"quinze", "ultimo", "ultima", "mitad", "doble", "menos", "mas", "minimo", "maximo", "mayoria", "minoria", "todos", "todas",
"seize", "todo", "toda", "cada", "solo", "sola", "encima", "debajo", "antes", "despues", "sobre", "bajo", "excepto",
"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_WORDS: usize = 2;
/// Shorter words have too many neighbours one letter away ("can" / "cant"). /// Shorter words have too many neighbours one letter away ("can" / "cant").
const MIN_TYPO_WORD: usize = 4; const MIN_TYPO_WORD: usize = 5;
/// Letters at each end of a word that a slip never touches: the start carries
/// prefixes ("valide" / "invalide"), the end carries number, gender, tense and
/// mood ("certifierai" / "certifierais", "será" / "sería").
const FIXED_ENDS: usize = 2;
/// Lowercase, strip diacritics. /// Lowercase, strip diacritics.
fn fold(s: &str) -> String { fn fold(s: &str) -> String {
@@ -197,15 +80,20 @@ fn skeleton(s: &str) -> String {
fold(s).chars().filter(|c| c.is_alphanumeric()).collect() fold(s).chars().filter(|c| c.is_alphanumeric()).collect()
} }
/// Symbols that carry a value, a relation or a grouping, wherever they stand. /// A letter of the Latin alphabet, precomposed accents included (after NFC):
const RELATIONS: &[char] = &[ /// the only letters a typo fix may touch. Any other script may hold look-alikes
'+', '±', '×', '÷', '^', '<', '>', '=', '≤', '≥', '≠', '≈', '%', '‰', '€', '$', '£', '∞', '√', '/', '(', ')', '[', /// (Cyrillic « а ») or reorder the display (Hebrew, Arabic).
']', '{', '}', '`', fn is_latin_letter(c: char) -> bool {
]; c.is_alphabetic() && matches!(c as u32, 0x41..=0x5A | 0x61..=0x7A | 0xC0..=0x24F | 0x1E00..=0x1EFF)
/// Signs: meaningful next to a figure ("-5", "5-10"). }
const SIGNS: &[char] = &['-', '−', '–'];
/// Separators: meaningful between two figures ("10 000", "10,000", "182.625"). /// Ordinary punctuation, free to change in a typo fix — except where noted below.
const SEPARATORS: &[char] = &['.', ',', ' ', '\u{a0}', '\u{202f}', '\'', '’']; 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").
const DASHES: &[char] = &['‐', '-', '–', '—'];
/// Invisible format characters (zero-width, bidirectional overrides and /// Invisible format characters (zero-width, bidirectional overrides and
/// isolates, byte order mark): they can make "12" display as "21" ("Trojan /// isolates, byte order mark): they can make "12" display as "21" ("Trojan
@@ -216,68 +104,104 @@ pub fn has_hidden_controls(s: &str) -> bool {
) )
} }
/// Everything a typo fix may not change, in order. Built the other way round
/// from a list of dangerous symbols: what is kept is everything that is NOT a
/// 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.
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());
cs.iter()
.enumerate()
.filter(|&(i, &c)| {
let (before, after) = (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 {
true
} else if DASHES.contains(&c) {
before || after
} else {
!PUNCTUATION.contains(&c)
}
})
.map(|(_, &c)| c)
.collect()
}
/// What stands between backticks: formulas and identifiers, compared exactly. /// What stands between backticks: formulas and identifiers, compared exactly.
fn code_spans(s: &str) -> Vec<&str> { fn code_spans(s: &str) -> Vec<&str> {
s.split('`').skip(1).step_by(2).collect() s.split('`').skip(1).step_by(2).collect()
} }
/// A single slip of the keyboard in a word: one letter added, removed, or two /// Web and mail addresses, domains, paths: compared exactly ("duniter" /
/// neighbours swapped — never at its start, where prefixes turn a meaning /// "dunniter" is a typo of a word, not of an address).
/// around ("valide" / "invalide", "limitée" / "illimitée"), and never a letter fn addresses(s: &str) -> Vec<String> {
/// replaced by another ("deux" / "dix", "majorité" / "minorité"). s.split_whitespace()
.map(|t| t.trim_matches(|c: char| !c.is_alphanumeric()))
.filter(|t| {
let cs: Vec<char> = t.chars().collect();
t.contains("://")
|| t.contains('@')
|| (1..cs.len().saturating_sub(1))
.any(|i| matches!(cs[i], '.' | '/') && cs[i - 1].is_alphanumeric() && cs[i + 1].is_alphanumeric())
})
.map(str::to_owned)
.collect()
}
/// A single slip of the keyboard inside a word: one letter added, removed, or
/// two neighbours swapped — never at either end (see `FIXED_ENDS`), and never a
/// letter replaced by another ("deux" / "dix", "majorité" / "minorité").
fn one_slip(x: &str, y: &str) -> bool { fn one_slip(x: &str, y: &str) -> bool {
let (a, b): (Vec<char>, Vec<char>) = (x.chars().collect(), y.chars().collect()); 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] { if a.len().min(b.len()) < MIN_TYPO_WORD {
return false; return false;
} }
let inside = |i: usize, len: usize| i >= FIXED_ENDS && i + FIXED_ENDS < len;
match a.len().abs_diff(b.len()) { match a.len().abs_diff(b.len()) {
1 => { 1 => {
let (short, long) = if a.len() < b.len() { (&a, &b) } else { (&b, &a) }; let (short, long) = if a.len() < b.len() { (&a, &b) } else { (&b, &a) };
(2..long.len()).any(|i| { (0..long.len()).any(|i| {
let mut l = long.clone(); let mut l = long.clone();
l.remove(i); l.remove(i);
&l == short inside(i, long.len()) && &l == short
}) })
} }
0 => { 0 => {
let at: Vec<usize> = (0..a.len()).filter(|&i| a[i] != b[i]).collect(); 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]] at.len() == 2
&& at[1] == at[0] + 1
&& a[at[0]] == b[at[1]]
&& a[at[1]] == b[at[0]]
&& inside(at[0], a.len())
&& inside(at[1], a.len())
} }
_ => false, _ => 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
/// say the same thing.
fn figures(s: &str) -> String {
let cs: Vec<char> = s.chars().collect();
let is_figure = |i: Option<usize>| i.and_then(|i| cs.get(i)).is_some_and(|c| c.is_numeric());
cs.iter()
.enumerate()
.filter(|&(i, c)| {
let (before, after) = (is_figure(i.checked_sub(1)), is_figure(Some(i + 1)));
c.is_numeric()
|| RELATIONS.contains(c)
|| (SIGNS.contains(c) && (before || after))
|| (SEPARATORS.contains(c) && before && after)
})
.map(|(_, c)| *c)
.collect()
}
/// Folded words (runs of letters/digits). /// Folded words (runs of letters/digits).
fn words(s: &str) -> Vec<String> { fn words(s: &str) -> Vec<String> {
fold(s).split(|c: char| !c.is_alphanumeric()).filter(|w| !w.is_empty()).map(str::to_owned).collect() fold(s).split(|c: char| !c.is_alphanumeric()).filter(|w| !w.is_empty()).map(str::to_owned).collect()
} }
/// Classify `new` against `old`. For a text under vote, `old` must be the text
/// that was **voted** (its last initial or substantive revision), not the
/// current one: typo fixes are measured together, so slips never add up.
pub fn classify(old: &str, new: &str) -> EditKind { pub fn classify(old: &str, new: &str) -> EditKind {
if old == new { if old == new {
return EditKind::Unchanged; return EditKind::Unchanged;
} }
// A figure, a sign or a relation is never a typo: it changes what the text says. if has_hidden_controls(new)
if figures(old) != figures(new) || code_spans(old) != code_spans(new) { || signature(old) != signature(new)
|| code_spans(old) != code_spans(new)
|| addresses(old) != addresses(new)
{
return EditKind::Substantive; return EditKind::Substantive;
} }
if skeleton(old) == skeleton(new) { if skeleton(old) == skeleton(new) {
@@ -468,6 +392,26 @@ mod tests {
("M(n)/N(n)", "M(n)N(n)"), ("M(n)/N(n)", "M(n)N(n)"),
("DU(n) - c", "DU(n) / c"), ("DU(n) - c", "DU(n) / c"),
("la formule `a - b`", "la formule `a * b`"), ("la formule `a - b`", "la formule `a * b`"),
// Fourth independent review: look-alikes, overlays, addresses, endings.
("au moins 50 voix", "au moins 5\u{201a}0 voix"),
("**12 mois**", "**1\u{201a}2 mois**"),
("15 jours", "1\u{2044}5 jours"),
("2 ans", "\u{ff0d}2 ans"),
(">= 5", "\u{226f}= 5"),
("a = b", "a \u{2260} b"),
("a = b", "a =\u{0338} b"),
("ne jamais certifier", "ne j\u{0336}a\u{0336}m\u{0336}a\u{0336}i\u{0336}s\u{0336} certifier"),
("la clé est vérifiée", "la clé est ~~vérifiée~~"),
("une personne connue", "une personne conn\u{05d5}ue"),
("une personne connue", "une personne c\u{043e}nnue"),
(
"https://git.duniter.org/documents/g1_monetary_license",
"https://git.dunniter.org/documents/g1_monetary_license",
),
("au-dessus de 50 %", "au-dessous de 50 %"),
("je certifierai", "je certifierais"),
("será válido", "sería válido"),
("they must certify", "they mustn certify"),
] { ] {
assert_eq!(classify(old, new), EditKind::Substantive, "{old} → {new}"); assert_eq!(classify(old, new), EditKind::Substantive, "{old} → {new}");
} }
@@ -482,7 +426,8 @@ mod tests {
#[test] #[test]
fn misspelt_words() { fn misspelt_words() {
assert_eq!(classify("un certifieurs existant", "un certifieur existant"), EditKind::Typographic); // The end of a word carries number and tense: a plural fixed is a revision.
assert_eq!(classify("un certifieurs existant", "un certifieur existant"), EditKind::Substantive);
assert_eq!(classify("toute certifcation", "toute certification"), EditKind::Typographic); assert_eq!(classify("toute certifcation", "toute certification"), EditKind::Typographic);
assert_eq!(classify("un acceuil chaleureux", "un accueil chaleureux"), 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. // A letter replaced by another is not a slip: the author revises.
+5 -1
View File
@@ -61,7 +61,11 @@ pub async fn open_db(url: &str) -> anyhow::Result<SqlitePool> {
.busy_timeout(Duration::from_secs(5)); .busy_timeout(Duration::from_secs(5));
let max = if url.contains(":memory:") { 1 } else { 8 }; let max = if url.contains(":memory:") { 1 } else { 8 };
let pool = SqlitePoolOptions::new().max_connections(max).connect_with(options).await?; let pool = SqlitePoolOptions::new().max_connections(max).connect_with(options).await?;
sqlx::migrate!("./migrations").run(&pool).await?; // A previous image (rollback) must still start on a database a newer one has
// migrated: migrations are additive, the unknown ones are tolerated.
let mut migrator = sqlx::migrate!("./migrations");
migrator.set_ignore_missing(true);
migrator.run(&pool).await?;
Ok(pool) Ok(pool)
} }
+34 -1
View File
@@ -71,6 +71,18 @@ fn check_text(title: &str, body: &str, needs_body: bool) -> AppResult<()> {
Ok(()) Ok(())
} }
/// The text the votes were cast on: its last initial or substantive revision.
async fn voted_text(state: &AppState, p: &ProposalRow) -> AppResult<(String, String)> {
let row: Option<(String, String)> = sqlx::query_as(
"SELECT title, body FROM revisions WHERE proposal_id = ? AND kind IN ('initial', 'substantive')
ORDER BY id DESC LIMIT 1",
)
.bind(&p.id)
.fetch_optional(&state.db)
.await?;
Ok(row.unwrap_or_else(|| (p.title.clone(), p.body.clone())))
}
async fn record_revision( async fn record_revision(
state: &AppState, state: &AppState,
proposal_id: &str, proposal_id: &str,
@@ -302,7 +314,18 @@ pub async fn edit(
if !matches!(p.status.as_str(), "open" | "in_force") { if !matches!(p.status.as_str(), "open" | "in_force") {
return Err(AppError::Conflict("closed")); return Err(AppError::Conflict("closed"));
} }
let kind = edit::classify(&format!("{}\n{}", p.title, p.body), &format!("{title}\n{body}")); // Measured against the text that was voted, not the current one: successive
// typo fixes are judged together and never add up to a change of meaning.
let next = format!("{title}\n{body}");
let kind = if next == format!("{}\n{}", p.title, p.body) {
EditKind::Unchanged
} else {
let (voted_title, voted_body) = voted_text(&state, &p).await?;
match edit::classify(&format!("{voted_title}\n{voted_body}"), &next) {
EditKind::Unchanged => EditKind::Typographic, // back to the voted text
k => k,
}
};
match kind { match kind {
EditKind::Unchanged => Ok(Json(json!({ "edit": "unchanged" }))), EditKind::Unchanged => Ok(Json(json!({ "edit": "unchanged" }))),
EditKind::Typographic => { EditKind::Typographic => {
@@ -379,6 +402,16 @@ pub async fn submit(
.bind(&id) .bind(&id)
.execute(&state.db) .execute(&state.db)
.await?; .await?;
// A draft is edited freely: the text submitted is the one votes will be cast on.
let initial = sqlx::query("UPDATE revisions SET title = ?, body = ? WHERE proposal_id = ? AND kind = 'initial'")
.bind(&p.title)
.bind(&p.body)
.bind(&id)
.execute(&state.db)
.await?;
if initial.rows_affected() == 0 {
record_revision(&state, &id, p.revision, "initial", &p.title, &p.body, Some(&user.id)).await?;
}
let entered = adoption::run(&state, &st.doc.id).await?; let entered = adoption::run(&state, &st.doc.id).await?;
Ok(Json(json!({ "status": "open", "entered": entered }))) Ok(Json(json!({ "status": "open", "entered": entered })))
} }
+28
View File
@@ -251,6 +251,34 @@ async fn editing_is_not_voting() {
) )
.await; .await;
assert_eq!((s, v["error"].as_str()), (StatusCode::BAD_REQUEST, Some("hidden_characters"))); assert_eq!((s, v["error"].as_str()), (StatusCode::BAD_REQUEST, Some("hidden_characters")));
// Typo fixes are measured against the voted text: slips never add up.
let patch = |body: String| json!({ "title": e2["title"], "body": body });
let (_, v) = b
.call("PATCH", &format!("/api/proposals/{e2_id}"), Some(patch(body.replacen("respectent", "repectent", 1))))
.await;
assert_eq!(v["edit"], "typo", "one slip is a typo");
let (s, v) = b
.call("PATCH", &format!("/api/proposals/{e2_id}"), Some(patch(body.replacen("respectent", "reectent", 1))))
.await;
assert_eq!(
(s, v["error"].as_str()),
(StatusCode::CONFLICT, Some("substantive_in_force")),
"a second slip on top is not"
);
let (_, v) = b.call("PATCH", &format!("/api/proposals/{e2_id}"), Some(patch(body.clone()))).await;
assert_eq!(v["edit"], "typo", "back to the voted text");
// An address is compared exactly.
let x11 = unit(&doc, "X1.1")["proposals"][0].clone();
let x11_body = x11["body"].as_str().expect("body");
assert!(x11_body.contains("git.duniter.org"));
let (s, v) = b
.call(
"PATCH",
&format!("/api/proposals/{}", x11["id"].as_str().expect("id")),
Some(json!({ "title": x11["title"], "body": x11_body.replacen("git.duniter.org", "git.dunniter.org", 1) })),
)
.await;
assert_eq!((s, v["error"].as_str()), (StatusCode::CONFLICT, Some("substantive_not_author")));
// A member proposal: typo keeps votes, substance asks to reconfirm. // A member proposal: typo keeps votes, substance asks to reconfirm.
let uid = unit(&doc, "E2")["id"].as_str().expect("u").to_owned(); let uid = unit(&doc, "E2")["id"].as_str().expect("u").to_owned();
+11 -6
View File
@@ -3,13 +3,18 @@
**Contexte.** « Ça ne peut pas devenir un système qui nécessite un vote pour corriger une **Contexte.** « Ça ne peut pas devenir un système qui nécessite un vote pour corriger une
coquille. » Mais une correction ne doit pas pouvoir changer le sens d'un texte voté. 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`) : **Décision.** Le moteur classe chaque modification (`ld-engine/src/edit.rs`), toujours
- **jamais coquille** si un chiffre, un signe, un séparateur dans un nombre, une relation ou **contre le texte voté** (sa dernière révision initiale ou de fond) — jamais contre la dernière
une parenthèse change, ni si quoi que ce soit change entre accents graves (formules) ; 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 ;
- **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 — une lettre ajoutée, retirée au plus 2 mots diffèrent chacun d'un seul glissement de clavier au cœur du mot — une lettre
ou deux voisines inversées, jamais au début du mot (les préfixes inversent le sens), jamais une ajoutée, retirée ou deux voisines inversées, jamais aux deux premières lettres (préfixes) ni
lettre remplacée, jamais sur un mot de négation ou de quantité ; elle s'applique sans vote, aux deux dernières (nombre, genre, temps, mode), jamais une lettre remplacée, jamais sur un mot
de négation, de quantité ou de position ; elle s'applique sans vote,
garde les votes et les traductions ; garde les votes et les traductions ;
- les caractères invisibles de mise en forme (inversion du sens d'écriture, largeur nulle) sont - 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 ; refusés partout où l'on écrit un texte ;
+20 -10
View File
@@ -76,19 +76,24 @@ l'inertie du cran au-dessus** (« inertie + ») : alourdir ou alléger demande p
| Geste | Qui | Effet sur les votes | | Geste | Qui | Effet sur les votes |
|---|---|---| |---|---|---|
| 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 | | Corriger une **coquille** (accents, casse, ponctuation, espaces ; ou jusqu'à 2 mots mal tapés, une lettre ajoutée, retirée ou inversée chacun, au cœur du mot) — mesurée contre le texte **voté**, jamais contre la dernière correction | 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'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 | | 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 — trois relectures indépendantes l'ont éprouvé. Sont toujours « de Le classement est prudent — quatre relectures indépendantes l'ont éprouvé, et il fonctionne
fond » : un chiffre, un signe, un séparateur dans un nombre, une relation, une parenthèse **par liste blanche** : une coquille ne peut toucher que des lettres latines, des espaces et la
changés (« 50 » → « 5,0 », « 182.625 » → « 182 625 », « ≥ » → « ≤ », « + » → « - », ponctuation ordinaire. Tout le reste compte comme le sens et ne bouge pas : chiffres (de toute
`ceil(N^(1/5))` → `ceil(N^1/5)`) ; tout ce qui est entre accents graves (formules) ; un mot de écriture), signes, relations, parenthèses, sosies (« ‚ » pour une virgule, « ⁄ » pour une barre,
négation ou de quantité touché (ne, pas, jamais, deux, dix, majorité, tous…) ; une lettre formes pleine chasse), marques superposées (rayure, barre), autres alphabets (cyrillique,
remplacée par une autre (« majorité » → « minorité ») ; le début d'un mot touché (« valide » → hébreu), rayure Markdown, tout ce qui est entre accents graves, les **adresses** (domaines,
« invalide », « limitée » → « illimitée »). Les caractères invisibles (inversion du sens liens, courriels), et un séparateur ou un tiret collé à un chiffre (« 50 » → « 5,0 »,
d'écriture, espaces de largeur nulle) sont refusés : ils peuvent faire lire « 21 » là où le texte « 182.625 » → « 182 625 », « -5 »). Pour les mots : au plus deux mots, chacun **un seul
dit « 12 ». Moteur : `crates/ld-engine/src/edit.rs`. 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
(ne, jamais, deux, majorité, tous, dessus, avant…). Chaque correction est mesurée contre le
**texte voté** : les glissements ne s'additionnent pas d'une correction à l'autre. Les caractères
invisibles (inversion du sens d'écriture, largeur nulle) sont refusés partout. Moteur : `crates/ld-engine/src/edit.rs`.
### Deux articles qui se contredisent ### Deux articles qui se contredisent
@@ -159,6 +164,11 @@ Les raisons longues sont dans `docs/adr/`.
**et** sous sa clé Ğ1 aurait deux voix : c'est à l'administration de n'en admettre qu'une. **et** sous sa clé Ğ1 aurait deux voix : c'est à l'administration de n'en admettre qu'une.
- Un administrateur peut créer un nouveau lien pour une identité d'invitation existante - Un administrateur peut créer un nouveau lien pour une identité d'invitation existante
(pour la dépanner) — jamais pour une identité Ğ1. (pour la dépanner) — jamais pour une identité Ğ1.
- Des accents ou une ponctuation qui portent du sens restent des coquilles (« ou » / « où »,
« a » / « à », une virgule autour d'une relative) : le diff de chaque correction est visible
dans l'historique de l'article.
- Les traductions (en, es) sont éditoriales : tout membre les corrige sans vote, et elles sont
republiées — le français fait foi.
## Questions ouvertes (à Yvv) ## Questions ouvertes (à Yvv)