v3 : troisième relecture — une coquille n'inverse plus un sens ; retour à la version précédente

- Mot mal tapé = un seul glissement (une lettre ajoutée, retirée, ou deux
  voisines inversées), jamais au début du mot (préfixes : valide/invalide,
  limitée/illimitée), jamais une lettre remplacée (deux/dix, majorité/minorité),
  jamais sur un mot de négation ou de quantité, mots de 4 lettres au moins.
- Parenthèses, crochets, « / » et tout ce qui est entre accents graves comptent
  comme le sens (ceil(N^(1/5)) → ceil(N^1/5) n'est plus une coquille).
- Caractères invisibles (inversion du sens d'écriture, largeur nulle, BOM)
  refusés dans les textes, titres, traductions et motifs de signalement.
- Déploiement : la version en service est gardée (image « previous ») ; un
  témoin marque le déploiement en cours ; « retablir » relance la maquette au
  premier déploiement, la version précédente ensuite ; la liste des conteneurs
  retirés n'est plus vidée par un pipeline annulé.
- Tests : 59 Rust (dont 14 scénarios d'API, avec deux → dix sur E2 en vigueur
  et l'inversion bidi), e2e 12 / 12.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Yvv
2026-10-21 05:21:08 +02:00
co-authored by Claude Opus 5.5
parent ac9c904e7e
commit 9203fd2501
10 changed files with 331 additions and 47 deletions
+25 -10
View File
@@ -34,6 +34,8 @@ 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)"
@@ -99,6 +101,8 @@ steps:
- /opt/libredecision-app:/opt/libredecision-app - /opt/libredecision-app:/opt/libredecision-app
commands: commands:
- DOMAIN=decision.librodrome.org - DOMAIN=decision.librodrome.org
# 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 - echo "APP_DOMAIN=$DOMAIN" > /opt/libredecision-app/.env
- cp docker/app-compose.yml /opt/libredecision-app/docker-compose.yml - cp docker/app-compose.yml /opt/libredecision-app/docker-compose.yml
# Tout conteneur, même arrêté, qui revendique encore la racine (la maquette avant son # Tout conteneur, même arrêté, qui revendique encore la racine (la maquette avant son
@@ -106,11 +110,11 @@ steps:
# « restart: always » : sinon un redémarrage de Docker le ramènerait, et fabio lui # « restart: always » : sinon un redémarrage de Docker le ramènerait, et fabio lui
# donnerait /api/* (route la plus longue). Rien n'est supprimé ; la maquette est # donnerait /api/* (route la plus longue). Rien n'est supprimé ; la maquette est
# recréée par son propre workflow, sous son nouveau domaine. # recréée par son propre workflow, sous son nouveau domaine.
# Ceux qui tournaient sont notés dans /opt/libredecision-app/retires : si la suite # Ceux qui tournaient sont ajoutés à /opt/libredecision-app/retires (jamais vidé ici :
# échoue, l'étape « retablir » les relance (et seulement eux). # un pipeline annulé ne perd pas sa liste) ; si la suite échoue, « retablir » les relance.
- | - |
RETIRES=/opt/libredecision-app/retires RETIRES=/opt/libredecision-app/retires
: > "$RETIRES" touch "$RETIRES"
for TAG in "urlprefix-$DOMAIN/*" "urlprefix-$DOMAIN/api/*"; do for TAG in "urlprefix-$DOMAIN/*" "urlprefix-$DOMAIN/api/*"; do
for C in $(docker ps -aq --filter "label=SERVICE_3000_TAGS=$TAG") $(docker ps -aq --filter "label=SERVICE_8002_TAGS=$TAG"); do for C in $(docker ps -aq --filter "label=SERVICE_3000_TAGS=$TAG") $(docker ps -aq --filter "label=SERVICE_8002_TAGS=$TAG"); do
NAME=$(docker inspect --format '{{.Name}}' "$C") NAME=$(docker inspect --format '{{.Name}}' "$C")
@@ -182,6 +186,7 @@ steps:
*'"ok":true'*"\"build\":\"$BUILD\""*|*"\"build\":\"$BUILD\""*'"ok":true'*) *'"ok":true'*"\"build\":\"$BUILD\""*|*"\"build\":\"$BUILD\""*'"ok":true'*)
echo "PASS: $BUILD repond sur $TARGET" echo "PASS: $BUILD repond sur $TARGET"
: > /opt/libredecision-app/retires : > /opt/libredecision-app/retires
rm -f /opt/libredecision-app/deploying
exit 0 ;; exit 0 ;;
esac esac
i=$((i+1)) i=$((i+1))
@@ -190,9 +195,10 @@ steps:
echo "FAIL: $BUILD ne repond pas apres 10 minutes" echo "FAIL: $BUILD ne repond pas apres 10 minutes"
exit 1 exit 1
# Échec après le retrait de la racine : le produit s'arrête, ce qu'il avait retiré # Échec avant le déploiement (construction, tests, SBOM) : rien n'a bougé, rien à faire.
# repart (restart: always rendu). Échec avant (construction, tests) : rien à faire, # Échec après, au premier déploiement : le produit s'arrête et ce qu'il avait retiré de la
# la liste est vide depuis le dernier déploiement réussi. # racine repart (restart: always rendu). Échec après, plus tard : la version précédente du
# produit (image « previous ») est relancée.
# NOTE: volumes + pas de from_secret : compatible # NOTE: volumes + pas de from_secret : compatible
- name: retablir - name: retablir
image: docker:27-cli image: docker:27-cli
@@ -205,17 +211,26 @@ steps:
- /opt/libredecision-app:/opt/libredecision-app - /opt/libredecision-app:/opt/libredecision-app
commands: commands:
- | - |
RETIRES=/opt/libredecision-app/retires D=/opt/libredecision-app
if [ ! -s "$RETIRES" ]; then echo "Rien a retablir"; exit 0; fi if [ ! -e "$D/deploying" ]; then echo "Echec avant le deploiement : rien a retablir"; exit 0; fi
if [ -s "$D/retires" ]; then
# Hors de « restart: always » aussi, sinon un redémarrage de Docker le ramènerait # Hors de « restart: always » aussi, sinon un redémarrage de Docker le ramènerait
# à côté de ce qui revient ; le prochain déploiement le recrée (--force-recreate). # à côté de ce qui revient ; le prochain déploiement le recrée (--force-recreate).
docker update --restart=no lpk-libredecision-app >/dev/null 2>&1 || true docker update --restart=no lpk-libredecision-app >/dev/null 2>&1 || true
docker stop lpk-libredecision-app >/dev/null 2>&1 || true docker stop lpk-libredecision-app >/dev/null 2>&1 || true
for C in $(cat "$RETIRES"); do for C in $(cat "$D/retires"); do
echo "Retour de $(docker inspect --format '{{.Name}}' "$C" 2>/dev/null || echo "$C")" echo "Retour de $(docker inspect --format '{{.Name}}' "$C" 2>/dev/null || echo "$C")"
docker update --restart=always "$C" >/dev/null && docker start "$C" >/dev/null || echo " echec pour $C" docker update --restart=always "$C" >/dev/null && docker start "$C" >/dev/null || echo " echec pour $C"
done done
: > "$RETIRES" : > "$D/retires"
elif docker image inspect libredecision-app:previous >/dev/null 2>&1; then
echo "Retour a la version precedente du produit"
docker tag libredecision-app:previous libredecision-app:latest
cd "$D" && docker compose up -d --force-recreate
else
echo "Aucune version precedente a relancer"
fi
rm -f "$D/deploying"
when: when:
- status: failure - status: failure
+8 -4
View File
@@ -7,7 +7,7 @@
**Chantier 1 — Acte d'engagement Certification** : construit, boucle verte, prêt à livrer. **Chantier 1 — Acte d'engagement Certification** : construit, boucle verte, prêt à livrer.
- v3 Rust + Svelte à la racine du dépôt ; la maquette v2 intacte dans `maquette/`. - v3 Rust + Svelte à la racine du dépôt ; la maquette v2 intacte dans `maquette/`.
- Boucle : Rust (fmt, clippy `-D warnings`, 57 tests dont 14 scénarios d'API et la - Boucle : Rust (fmt, clippy `-D warnings`, 59 tests dont 14 scénarios d'API et la
publication vers un dépôt nu) · interface (svelte-check 0 avertissement, 9 vitest) · publication vers un dépôt nu) · interface (svelte-check 0 avertissement, 9 vitest) ·
e2e 12 / 12 (C1–C11, M1). L'image Docker rejoue tout et a été essayée localement : e2e 12 / 12 (C1–C11, M1). L'image Docker rejoue tout et a été essayée localement :
santé, interface, volume non-root, clé de publication, lien admin initial (0600, absent santé, interface, volume non-root, clé de publication, lien admin initial (0600, absent
@@ -15,11 +15,15 @@
- 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).
- Relecture indépendante avant mise en ligne (`1f9c241 : ok`) ; ses points « à corriger - Trois relectures indépendantes avant mise en ligne (`1f9c241 : ok`, `94907af : ok`,
ensuite » ont été traités avant de livrer : ordre des workflows, anciens conteneurs en `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
`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,
cookies préfixés `__Host-`, retour arrière automatique du déploiement (maquette au premier,
version précédente ensuite), syft épinglé.
## Ce qui attend Yvv ## Ce qui attend Yvv
+245 -17
View File
@@ -4,9 +4,11 @@
//! tells two kinds of corrections apart: //! tells two kinds of corrections apart:
//! //!
//! - **typographic**: accents, case, punctuation, spacing, emphasis, or at most //! - **typographic**: accents, case, punctuation, spacing, emphasis, or at most
//! two misspelt words fixed (edit distance ≤ 2, no negation touched) — and //! two misspelt words fixed by a single slip each (one letter added, removed
//! never a figure, a sign, a separator inside a number or a relation //! or swapped, never at the start of the word, never on a negation or a
//! (`≥`, `+`, `%`…). The votes stay; the correction is shown with its diff. //! quantity) — and never a figure, a sign, a separator inside a number, a
//! relation, a bracket or anything between backticks. The votes stay; the
//! correction is shown with its diff.
//! - **substantive**: anything else. It opens a new revision and every vote //! - **substantive**: anything else. It opens a new revision and every vote
//! cast on the previous one waits for its author to reconfirm it. //! cast on the previous one waits for its author to reconfirm it.
//! //!
@@ -32,8 +34,154 @@ 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.
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",
];
const MAX_TYPO_WORDS: usize = 2; const MAX_TYPO_WORDS: usize = 2;
const MAX_TYPO_DISTANCE: usize = 2; /// Shorter words have too many neighbours one letter away ("can" / "cant").
const MIN_TYPO_WORD: usize = 4;
/// Lowercase, strip diacritics. /// Lowercase, strip diacritics.
fn fold(s: &str) -> String { fn fold(s: &str) -> String {
@@ -49,14 +197,56 @@ 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 or a relation, wherever they stand. /// Symbols that carry a value, a relation or a grouping, wherever they stand.
const RELATIONS: &[char] = const RELATIONS: &[char] = &[
&['+', '±', '×', '÷', '^', '<', '>', '=', '≤', '≥', '≠', '≈', '%', '‰', '€', '$', '£', '∞', '√']; '+', '±', '×', '÷', '^', '<', '>', '=', '≤', '≥', '≠', '≈', '%', '‰', '€', '$', '£', '∞', '√', '/', '(', ')', '[',
/// Signs and fraction bars: meaningful next to a figure ("-5", "1/5", "5-10"). ']', '{', '}', '`',
const SIGNS: &[char] = &['-', '−', '–', '/']; ];
/// Signs: meaningful next to a figure ("-5", "5-10").
const SIGNS: &[char] = &['-', '−', '–'];
/// Separators: meaningful between two figures ("10 000", "10,000", "182.625"). /// Separators: meaningful between two figures ("10 000", "10,000", "182.625").
const SEPARATORS: &[char] = &['.', ',', ' ', '\u{a0}', '\u{202f}', '\'', '’']; const SEPARATORS: &[char] = &['.', ',', ' ', '\u{a0}', '\u{202f}', '\'', '’'];
/// Invisible format characters (zero-width, bidirectional overrides and
/// isolates, byte order mark): they can make "12" display as "21" ("Trojan
/// Source"). A text of reference never needs them; they are refused.
pub fn has_hidden_controls(s: &str) -> bool {
s.chars().any(
|c| matches!(c as u32, 0x200B..=0x200F | 0x202A..=0x202E | 0x2060..=0x2064 | 0x2066..=0x2069 | 0x061C | 0xFEFF),
)
}
/// 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é").
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] {
return false;
}
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| {
let mut l = long.clone();
l.remove(i);
&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]]
}
_ => false,
}
}
/// What the text says in figures and symbols, as written. Two texts with the /// 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 /// same letters but a different figure, sign, separator inside a number or
/// relation ("≥" → "≤", "+" → "-", "50" → "5,0", "182.625" → "182 625") do not /// relation ("≥" → "≤", "+" → "-", "50" → "5,0", "182.625" → "182 625") do not
@@ -87,7 +277,7 @@ pub fn classify(old: &str, new: &str) -> EditKind {
return EditKind::Unchanged; return EditKind::Unchanged;
} }
// A figure, a sign or a relation is never a typo: it changes what the text says. // A figure, a sign or a relation is never a typo: it changes what the text says.
if figures(old) != figures(new) { if figures(old) != figures(new) || code_spans(old) != code_spans(new) {
return EditKind::Substantive; return EditKind::Substantive;
} }
if skeleton(old) == skeleton(new) { if skeleton(old) == skeleton(new) {
@@ -104,12 +294,10 @@ pub fn classify(old: &str, new: &str) -> EditKind {
} }
changed += 1; changed += 1;
let touches_meaning = changed > MAX_TYPO_WORDS let touches_meaning = changed > MAX_TYPO_WORDS
|| x.chars().any(|c| c.is_ascii_digit()) || x.chars().any(char::is_numeric)
|| y.chars().any(|c| c.is_ascii_digit()) || y.chars().any(char::is_numeric)
|| NEGATIONS.contains(&x.as_str()) || [x, y].iter().any(|w| NEGATIONS.contains(&w.as_str()) || QUANTITIES.contains(&w.as_str()))
|| NEGATIONS.contains(&y.as_str()) || !one_slip(x, y);
|| x.chars().count().min(y.chars().count()) < 3
|| levenshtein(x, y) > MAX_TYPO_DISTANCE;
if touches_meaning { if touches_meaning {
return EditKind::Substantive; return EditKind::Substantive;
} }
@@ -251,14 +439,54 @@ mod tests {
// Punctuation around figures, list markers, dashes between words stay typographic. // Punctuation around figures, list markers, dashes between words stay typographic.
assert_eq!(classify("50 voix pour", "50 voix pour !"), EditKind::Typographic); assert_eq!(classify("50 voix pour", "50 voix pour !"), EditKind::Typographic);
assert_eq!(classify("- une liste", "* une liste"), EditKind::Typographic); assert_eq!(classify("- une liste", "* une liste"), EditKind::Typographic);
assert_eq!(classify("1. premier point", "1) premier point"), EditKind::Typographic); // A bracket always counts, even as a list marker: conservative.
assert_eq!(classify("1. premier point", "1) premier point"), EditKind::Substantive);
assert_eq!(classify("1. premier point", "1. premier point."), EditKind::Typographic);
assert_eq!(classify("libre - le texte", "libre — le texte"), EditKind::Typographic); assert_eq!(classify("libre - le texte", "libre — le texte"), EditKind::Typographic);
} }
#[test]
fn a_word_turned_around_is_never_a_typo() {
// Third independent review, 2026-10-21: one or two letters that invert a meaning.
for (old, new) in [
("ces deux présents engagements", "ces dix présents engagements"),
("Nouveau compte valide si", "Nouveau compte invalide si"),
("100 certifications possibles", "100 certifications impossibles"),
("à la majorité des voix", "à la minorité des voix"),
("un minimum de garanties", "un maximum de garanties"),
("les membres inclus", "les membres exclus"),
("une personne majeure", "une personne mineure"),
("une durée limitée", "une durée illimitée"),
("un mandat révocable", "un mandat irrévocable"),
("des comptes indépendants", "des comptes dépendants"),
("pendant six mois", "pendant dix mois"),
("sept certifications", "cent certifications"),
("seize jours", "treize jours"),
("five members", "nine members"),
("you can certify", "you cant certify"),
("Y = ceil(N^(1/5))", "Y = ceil(N^1/5)"),
("M(n)/N(n)", "M(n)N(n)"),
("DU(n) - c", "DU(n) / c"),
("la formule `a - b`", "la formule `a * b`"),
] {
assert_eq!(classify(old, new), EditKind::Substantive, "{old} → {new}");
}
}
#[test]
fn hidden_controls_are_spotted() {
assert!(has_hidden_controls("**\u{202e}12\u{202c} mois**"));
assert!(has_hidden_controls("5\u{200b}0"));
assert!(!has_hidden_controls("Je m’engage — « à 50 % »."));
}
#[test] #[test]
fn misspelt_words() { fn misspelt_words() {
assert_eq!(classify("un certifieurs existant", "un certifieur existant"), EditKind::Typographic); assert_eq!(classify("un certifieurs existant", "un certifieur existant"), EditKind::Typographic);
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);
// A letter replaced by another is not a slip: the author revises.
assert_eq!(classify("une certifixation", "une certification"), EditKind::Substantive);
// « personne » is also a negation (ne … personne): conservative. // « personne » is also a negation (ne … personne): conservative.
assert_eq!(classify("la persone", "la personne"), EditKind::Substantive); assert_eq!(classify("la persone", "la personne"), EditKind::Substantive);
} }
+4 -1
View File
@@ -59,6 +59,9 @@ async fn guarded(state: &AppState, user: &IdentityRow, proposal_id: &str) -> App
} }
fn check_text(title: &str, body: &str, needs_body: bool) -> AppResult<()> { fn check_text(title: &str, body: &str, needs_body: bool) -> AppResult<()> {
if edit::has_hidden_controls(title) || edit::has_hidden_controls(body) {
return Err(AppError::Invalid("hidden_characters"));
}
if title.trim().is_empty() || title.chars().count() > MAX_TITLE { if title.trim().is_empty() || title.chars().count() > MAX_TITLE {
return Err(AppError::Invalid("title")); return Err(AppError::Invalid("title"));
} }
@@ -647,7 +650,7 @@ pub async fn signal(
) -> AppResult<Json<serde_json::Value>> { ) -> AppResult<Json<serde_json::Value>> {
let (p, st) = guarded(&state, &user, &id).await?; let (p, st) = guarded(&state, &user, &id).await?;
let reason = b.reason.trim(); let reason = b.reason.trim();
if reason.chars().count() < MIN_REASON || reason.chars().count() > MAX_REASON { if reason.chars().count() < MIN_REASON || reason.chars().count() > MAX_REASON || edit::has_hidden_controls(reason) {
return Err(AppError::Invalid("reason")); return Err(AppError::Invalid("reason"));
} }
if b.target_id == id { if b.target_id == id {
+21
View File
@@ -230,6 +230,27 @@ async fn editing_is_not_voting() {
.await; .await;
assert_eq!(s, StatusCode::CONFLICT, "{v}"); assert_eq!(s, StatusCode::CONFLICT, "{v}");
assert_eq!(v["error"], "substantive_not_author"); assert_eq!(v["error"], "substantive_not_author");
// One word turned around in a text in force, and an invisible bidi override: refused.
let e2 = unit(&doc, "E2")["inForce"].clone();
let e2_id = e2["id"].as_str().expect("id").to_owned();
let body = e2["body"].as_str().expect("body").to_owned();
assert!(body.contains("ces deux présents"));
let (s, v) = b
.call(
"PATCH",
&format!("/api/proposals/{e2_id}"),
Some(json!({ "title": e2["title"], "body": body.replacen("deux", "dix", 1) })),
)
.await;
assert_eq!((s, v["error"].as_str()), (StatusCode::CONFLICT, Some("substantive_in_force")));
let (s, v) = b
.call(
"PATCH",
&format!("/api/proposals/{e2_id}"),
Some(json!({ "title": e2["title"], "body": format!("\u{202e}{body}") })),
)
.await;
assert_eq!((s, v["error"].as_str()), (StatusCode::BAD_REQUEST, Some("hidden_characters")));
// 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();
+8 -4
View File
@@ -4,11 +4,15 @@
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`) :
- **jamais coquille** si un chiffre, un signe, un séparateur dans un nombre ou une relation - **jamais coquille** si un chiffre, un signe, un séparateur dans un nombre, une relation ou
change (« 50 » → « 5,0 », « 182.625 » → « 182 625 », « ≥ » → « ≤ », « + » → « - ») ; une parenthèse change, ni si quoi que ce soit change entre accents graves (formules) ;
- **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 de 2 lettres au plus, sans chiffre ni mot de négation (ne, pas, au plus 2 mots diffèrent chacun d'un seul glissement de clavier — une lettre ajoutée, retirée
jamais, personne…) ; elle s'applique sans vote, garde les votes et les traductions ; 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,
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 ;
- **de fond** sinon : nouvelle révision d'une proposition ouverte (par son auteur), votes - **de fond** sinon : nouvelle révision d'une proposition ouverte (par son auteur), votes
antérieurs « à reconfirmer », signalements levés ; refusée sur un texte en vigueur. antérieurs « à reconfirmer », signalements levés ; refusée sur un texte en vigueur.
L'éditeur montre le verdict avant d'enregistrer. Les brouillons s'éditent librement. L'éditeur montre le verdict avant d'enregistrer. Les brouillons s'éditent librement.
+10 -4
View File
@@ -76,13 +76,19 @@ 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 à 2 lettres près) | 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) | 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 : un chiffre, un signe, un séparateur dans un nombre ou une relation Le classement est prudent — trois relectures indépendantes l'ont éprouvé. Sont toujours « de
changés (« 50 » → « 5,0 », « 182.625 » → « 182 625 », « ≥ » → « ≤ », « + » → « - », « % ») fond » : un chiffre, un signe, un séparateur dans un nombre, une relation, une parenthèse
ou un mot de négation touché (ne, pas, jamais, personne…) rendent la modification « de fond ». Moteur : `crates/ld-engine/src/edit.rs`. 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`.
### Deux articles qui se contredisent ### Deux articles qui se contredisent
+1
View File
@@ -311,6 +311,7 @@ export const en: Messages = {
'error.g1_not_configured': "Ğ1 sign-in isn't active yet.", 'error.g1_not_configured': "Ğ1 sign-in isn't active yet.",
'error.invitation_invalid': 'This invitation link is no longer valid.', 'error.invitation_invalid': 'This invitation link is no longer valid.',
'error.identity_kind': 'A new link can only be for an invited person, never a Ğ1 identity.', 'error.identity_kind': 'A new link can only be for an invited person, never a Ğ1 identity.',
'error.hidden_characters': 'The text contains invisible characters (writing-direction overrides, zero-width spaces): remove them.',
'error.wot_members_only': 'Here, only members of the Ğ1 web of trust vote.', 'error.wot_members_only': 'Here, only members of the Ğ1 web of trust vote.',
'error.retry': 'Try again', 'error.retry': 'Try again',
+1
View File
@@ -311,6 +311,7 @@ export const es: Messages = {
'error.g1_not_configured': 'La conexión Ğ1 aún no está activa.', 'error.g1_not_configured': 'La conexión Ğ1 aún no está activa.',
'error.invitation_invalid': 'Este enlace de invitación ya no es válido.', 'error.invitation_invalid': 'Este enlace de invitación ya no es válido.',
'error.identity_kind': 'Un nuevo enlace solo puede ser para una persona invitada, nunca para una identidad Ğ1.', 'error.identity_kind': 'Un nuevo enlace solo puede ser para una persona invitada, nunca para una identidad Ğ1.',
'error.hidden_characters': 'El texto contiene caracteres invisibles (inversión del sentido de escritura, espacios de ancho cero): quítalos.',
'error.wot_members_only': 'Aquí solo votan los miembros de la red de confianza Ğ1.', 'error.wot_members_only': 'Aquí solo votan los miembros de la red de confianza Ğ1.',
'error.retry': 'Reintentar', 'error.retry': 'Reintentar',
+1
View File
@@ -314,6 +314,7 @@ export const fr = {
'error.g1_not_configured': "La connexion Ğ1 n'est pas encore active.", 'error.g1_not_configured': "La connexion Ğ1 n'est pas encore active.",
'error.invitation_invalid': "Ce lien d'invitation n'est plus valable.", 'error.invitation_invalid': "Ce lien d'invitation n'est plus valable.",
'error.identity_kind': "Un nouveau lien ne peut viser qu'une personne invitée, jamais une identité Ğ1.", 'error.identity_kind': "Un nouveau lien ne peut viser qu'une personne invitée, jamais une identité Ğ1.",
'error.hidden_characters': 'Le texte contient des caractères invisibles (inversion du sens d\'écriture, espaces de largeur nulle) : retire-les.',
'error.wot_members_only': 'Ici, seuls les membres de la toile de confiance Ğ1 votent.', 'error.wot_members_only': 'Ici, seuls les membres de la toile de confiance Ğ1 votent.',
'error.retry': 'Réessayer', 'error.retry': 'Réessayer',