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:
+5
-2
@@ -34,8 +34,6 @@ steps:
|
||||
- /var/run/docker.sock:/var/run/docker.sock
|
||||
commands:
|
||||
- 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 .
|
||||
- echo "Image app construite ($BUILD)"
|
||||
|
||||
@@ -127,6 +125,11 @@ 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
|
||||
- |
|
||||
|
||||
+6
-4
@@ -15,10 +15,12 @@
|
||||
- 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
|
||||
sur maquette.decision.librodrome.org, seulement si le produit a réussi).
|
||||
- Trois relectures indépendantes avant mise en ligne (`1f9c241 : ok`, `94907af : ok`,
|
||||
`ac9c904 : à reprendre`), chacune traitée avant de livrer. La troisième a montré qu'une
|
||||
« coquille » pouvait encore inverser un sens (« valide » → « invalide », « deux » → « dix »,
|
||||
caractères invisibles) : la règle a été resserrée (voir CHANTIER.md). Les autres points : ordre des workflows, anciens conteneurs en
|
||||
- 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
|
||||
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
|
||||
autres points : ordre des workflows, anciens conteneurs en
|
||||
`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,
|
||||
un nombre n'est jamais une coquille, redirection après connexion, en-têtes anti-encadrement,
|
||||
|
||||
+127
-182
@@ -34,154 +34,37 @@ 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.
|
||||
/// Words that carry a quantity, a scope or a relation (folded); changing one is
|
||||
/// never a typo.
|
||||
#[rustfmt::skip]
|
||||
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",
|
||||
"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", "dessus", "dessous", "superieur",
|
||||
"superieure", "inferieur", "inferieure", "avant", "apres", "sous", "entre", "hors", "sauf", "inclus", "exclus",
|
||||
// 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", "above", "below",
|
||||
"over", "under", "before", "after", "within", "except",
|
||||
// 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", "encima", "debajo", "antes", "despues", "sobre", "bajo", "excepto",
|
||||
];
|
||||
|
||||
const MAX_TYPO_WORDS: usize = 2;
|
||||
/// 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.
|
||||
fn fold(s: &str) -> String {
|
||||
@@ -197,15 +80,20 @@ fn skeleton(s: &str) -> String {
|
||||
fold(s).chars().filter(|c| c.is_alphanumeric()).collect()
|
||||
}
|
||||
|
||||
/// 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}', '\'', '’'];
|
||||
/// A letter of the Latin alphabet, precomposed accents included (after NFC):
|
||||
/// 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)
|
||||
}
|
||||
|
||||
/// 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").
|
||||
const DASHES: &[char] = &['‐', '-', '–', '—'];
|
||||
|
||||
/// Invisible format characters (zero-width, bidirectional overrides and
|
||||
/// 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.
|
||||
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é").
|
||||
/// Web and mail addresses, domains, paths: compared exactly ("duniter" /
|
||||
/// "dunniter" is a typo of a word, not of an address).
|
||||
fn addresses(s: &str) -> Vec<String> {
|
||||
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 {
|
||||
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;
|
||||
}
|
||||
let inside = |i: usize, len: usize| i >= FIXED_ENDS && i + FIXED_ENDS < len;
|
||||
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| {
|
||||
(0..long.len()).any(|i| {
|
||||
let mut l = long.clone();
|
||||
l.remove(i);
|
||||
&l == short
|
||||
inside(i, long.len()) && &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]]
|
||||
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,
|
||||
}
|
||||
}
|
||||
|
||||
/// 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).
|
||||
fn words(s: &str) -> Vec<String> {
|
||||
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 {
|
||||
if old == new {
|
||||
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) || code_spans(old) != code_spans(new) {
|
||||
if has_hidden_controls(new)
|
||||
|| signature(old) != signature(new)
|
||||
|| code_spans(old) != code_spans(new)
|
||||
|| addresses(old) != addresses(new)
|
||||
{
|
||||
return EditKind::Substantive;
|
||||
}
|
||||
if skeleton(old) == skeleton(new) {
|
||||
@@ -468,6 +392,26 @@ mod tests {
|
||||
("M(n)/N(n)", "M(n)N(n)"),
|
||||
("DU(n) - c", "DU(n) / c"),
|
||||
("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}");
|
||||
}
|
||||
@@ -482,7 +426,8 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
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("un acceuil chaleureux", "un accueil chaleureux"), EditKind::Typographic);
|
||||
// A letter replaced by another is not a slip: the author revises.
|
||||
|
||||
@@ -61,7 +61,11 @@ pub async fn open_db(url: &str) -> anyhow::Result<SqlitePool> {
|
||||
.busy_timeout(Duration::from_secs(5));
|
||||
let max = if url.contains(":memory:") { 1 } else { 8 };
|
||||
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)
|
||||
}
|
||||
|
||||
|
||||
@@ -71,6 +71,18 @@ fn check_text(title: &str, body: &str, needs_body: bool) -> AppResult<()> {
|
||||
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(
|
||||
state: &AppState,
|
||||
proposal_id: &str,
|
||||
@@ -302,7 +314,18 @@ pub async fn edit(
|
||||
if !matches!(p.status.as_str(), "open" | "in_force") {
|
||||
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 {
|
||||
EditKind::Unchanged => Ok(Json(json!({ "edit": "unchanged" }))),
|
||||
EditKind::Typographic => {
|
||||
@@ -379,6 +402,16 @@ pub async fn submit(
|
||||
.bind(&id)
|
||||
.execute(&state.db)
|
||||
.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?;
|
||||
Ok(Json(json!({ "status": "open", "entered": entered })))
|
||||
}
|
||||
|
||||
@@ -251,6 +251,34 @@ async fn editing_is_not_voting() {
|
||||
)
|
||||
.await;
|
||||
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.
|
||||
let uid = unit(&doc, "E2")["id"].as_str().expect("u").to_owned();
|
||||
|
||||
@@ -3,13 +3,18 @@
|
||||
**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é.
|
||||
|
||||
**Décision.** Le moteur classe chaque modification (`ld-engine/src/edit.rs`) :
|
||||
- **jamais coquille** si un chiffre, un signe, un séparateur dans un nombre, une relation ou
|
||||
une parenthèse change, ni si quoi que ce soit change entre accents graves (formules) ;
|
||||
**Décision.** Le moteur classe chaque modification (`ld-engine/src/edit.rs`), toujours
|
||||
**contre le texte voté** (sa dernière révision initiale ou de fond) — jamais contre la dernière
|
||||
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
|
||||
au plus 2 mots diffèrent chacun d'un seul glissement de clavier — une lettre ajoutée, retirée
|
||||
ou deux voisines inversées, jamais au début du mot (les préfixes inversent le sens), jamais une
|
||||
lettre remplacée, jamais sur un mot de négation ou de quantité ; elle s'applique sans vote,
|
||||
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
|
||||
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 ;
|
||||
- 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 ;
|
||||
|
||||
@@ -76,19 +76,24 @@ l'inertie du cran au-dessus** (« inertie + ») : alourdir ou alléger demande p
|
||||
|
||||
| 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'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
|
||||
fond » : un chiffre, un signe, un séparateur dans un nombre, une relation, une parenthèse
|
||||
changés (« 50 » → « 5,0 », « 182.625 » → « 182 625 », « ≥ » → « ≤ », « + » → « - »,
|
||||
`ceil(N^(1/5))` → `ceil(N^1/5)`) ; tout ce qui est entre accents graves (formules) ; un mot de
|
||||
négation ou de quantité touché (ne, pas, jamais, deux, dix, majorité, tous…) ; une lettre
|
||||
remplacée par une autre (« majorité » → « minorité ») ; le début d'un mot touché (« valide » →
|
||||
« invalide », « limitée » → « illimitée »). Les caractères invisibles (inversion du sens
|
||||
d'écriture, espaces de largeur nulle) sont refusés : ils peuvent faire lire « 21 » là où le texte
|
||||
dit « 12 ». Moteur : `crates/ld-engine/src/edit.rs`.
|
||||
Le classement est prudent — quatre relectures indépendantes l'ont éprouvé, et il fonctionne
|
||||
**par liste blanche** : une coquille ne peut toucher que des lettres latines, des espaces et la
|
||||
ponctuation ordinaire. Tout le reste compte comme le sens et ne bouge pas : chiffres (de toute
|
||||
é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
|
||||
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
|
||||
|
||||
@@ -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.
|
||||
- Un administrateur peut créer un nouveau lien pour une identité d'invitation existante
|
||||
(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)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user