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:
+25
-10
@@ -34,6 +34,8 @@ 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)"
|
||||
|
||||
@@ -99,6 +101,8 @@ steps:
|
||||
- /opt/libredecision-app:/opt/libredecision-app
|
||||
commands:
|
||||
- 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
|
||||
- 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
|
||||
@@ -106,11 +110,11 @@ steps:
|
||||
# « 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
|
||||
# recréée par son propre workflow, sous son nouveau domaine.
|
||||
# Ceux qui tournaient sont notés dans /opt/libredecision-app/retires : si la suite
|
||||
# échoue, l'étape « retablir » les relance (et seulement eux).
|
||||
# Ceux qui tournaient sont ajoutés à /opt/libredecision-app/retires (jamais vidé ici :
|
||||
# un pipeline annulé ne perd pas sa liste) ; si la suite échoue, « retablir » les relance.
|
||||
- |
|
||||
RETIRES=/opt/libredecision-app/retires
|
||||
: > "$RETIRES"
|
||||
touch "$RETIRES"
|
||||
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
|
||||
NAME=$(docker inspect --format '{{.Name}}' "$C")
|
||||
@@ -182,6 +186,7 @@ steps:
|
||||
*'"ok":true'*"\"build\":\"$BUILD\""*|*"\"build\":\"$BUILD\""*'"ok":true'*)
|
||||
echo "PASS: $BUILD repond sur $TARGET"
|
||||
: > /opt/libredecision-app/retires
|
||||
rm -f /opt/libredecision-app/deploying
|
||||
exit 0 ;;
|
||||
esac
|
||||
i=$((i+1))
|
||||
@@ -190,9 +195,10 @@ steps:
|
||||
echo "FAIL: $BUILD ne repond pas apres 10 minutes"
|
||||
exit 1
|
||||
|
||||
# Échec après le retrait de la racine : le produit s'arrête, ce qu'il avait retiré
|
||||
# repart (restart: always rendu). Échec avant (construction, tests) : rien à faire,
|
||||
# la liste est vide depuis le dernier déploiement réussi.
|
||||
# Échec avant le déploiement (construction, tests, SBOM) : rien n'a bougé, rien à faire.
|
||||
# Échec après, au premier déploiement : le produit s'arrête et ce qu'il avait retiré de la
|
||||
# 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
|
||||
- name: retablir
|
||||
image: docker:27-cli
|
||||
@@ -205,17 +211,26 @@ steps:
|
||||
- /opt/libredecision-app:/opt/libredecision-app
|
||||
commands:
|
||||
- |
|
||||
RETIRES=/opt/libredecision-app/retires
|
||||
if [ ! -s "$RETIRES" ]; then echo "Rien a retablir"; exit 0; fi
|
||||
D=/opt/libredecision-app
|
||||
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
|
||||
# à 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 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")"
|
||||
docker update --restart=always "$C" >/dev/null && docker start "$C" >/dev/null || echo " echec pour $C"
|
||||
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:
|
||||
- status: failure
|
||||
|
||||
|
||||
+8
-4
@@ -7,7 +7,7 @@
|
||||
**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/`.
|
||||
- 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) ·
|
||||
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
|
||||
@@ -15,11 +15,15 @@
|
||||
- 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).
|
||||
- Relecture indépendante avant mise en ligne (`1f9c241 : ok`) ; ses points « à corriger
|
||||
ensuite » ont été traités avant de livrer : ordre des workflows, anciens conteneurs en
|
||||
- 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
|
||||
`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.
|
||||
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
|
||||
|
||||
|
||||
+245
-17
@@ -4,9 +4,11 @@
|
||||
//! tells two kinds of corrections apart:
|
||||
//!
|
||||
//! - **typographic**: accents, case, punctuation, spacing, emphasis, or at most
|
||||
//! two misspelt words fixed (edit distance ≤ 2, no negation touched) — and
|
||||
//! never a figure, a sign, a separator inside a number or a relation
|
||||
//! (`≥`, `+`, `%`…). The votes stay; the correction is shown with its diff.
|
||||
//! two misspelt words fixed by a single slip each (one letter added, removed
|
||||
//! or swapped, never at the start of the word, never on a negation or a
|
||||
//! 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
|
||||
//! 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",
|
||||
];
|
||||
|
||||
/// 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_DISTANCE: usize = 2;
|
||||
/// Shorter words have too many neighbours one letter away ("can" / "cant").
|
||||
const MIN_TYPO_WORD: usize = 4;
|
||||
|
||||
/// Lowercase, strip diacritics.
|
||||
fn fold(s: &str) -> String {
|
||||
@@ -49,14 +197,56 @@ fn skeleton(s: &str) -> String {
|
||||
fold(s).chars().filter(|c| c.is_alphanumeric()).collect()
|
||||
}
|
||||
|
||||
/// Symbols that carry a value or a relation, wherever they stand.
|
||||
const RELATIONS: &[char] =
|
||||
&['+', '±', '×', '÷', '^', '<', '>', '=', '≤', '≥', '≠', '≈', '%', '‰', '€', '$', '£', '∞', '√'];
|
||||
/// Signs and fraction bars: meaningful next to a figure ("-5", "1/5", "5-10").
|
||||
const SIGNS: &[char] = &['-', '−', '–', '/'];
|
||||
/// 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}', '\'', '’'];
|
||||
|
||||
/// 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
|
||||
/// same letters but a different figure, sign, separator inside a number or
|
||||
/// 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;
|
||||
}
|
||||
// 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;
|
||||
}
|
||||
if skeleton(old) == skeleton(new) {
|
||||
@@ -104,12 +294,10 @@ pub fn classify(old: &str, new: &str) -> EditKind {
|
||||
}
|
||||
changed += 1;
|
||||
let touches_meaning = changed > MAX_TYPO_WORDS
|
||||
|| x.chars().any(|c| c.is_ascii_digit())
|
||||
|| y.chars().any(|c| c.is_ascii_digit())
|
||||
|| NEGATIONS.contains(&x.as_str())
|
||||
|| NEGATIONS.contains(&y.as_str())
|
||||
|| x.chars().count().min(y.chars().count()) < 3
|
||||
|| levenshtein(x, y) > MAX_TYPO_DISTANCE;
|
||||
|| x.chars().any(char::is_numeric)
|
||||
|| y.chars().any(char::is_numeric)
|
||||
|| [x, y].iter().any(|w| NEGATIONS.contains(&w.as_str()) || QUANTITIES.contains(&w.as_str()))
|
||||
|| !one_slip(x, y);
|
||||
if touches_meaning {
|
||||
return EditKind::Substantive;
|
||||
}
|
||||
@@ -251,14 +439,54 @@ mod tests {
|
||||
// Punctuation around figures, list markers, dashes between words stay typographic.
|
||||
assert_eq!(classify("50 voix pour", "50 voix pour !"), 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);
|
||||
}
|
||||
|
||||
#[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]
|
||||
fn misspelt_words() {
|
||||
assert_eq!(classify("un certifieurs existant", "un certifieur existant"), 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.
|
||||
assert_eq!(classify("la persone", "la personne"), EditKind::Substantive);
|
||||
}
|
||||
|
||||
@@ -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<()> {
|
||||
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 {
|
||||
return Err(AppError::Invalid("title"));
|
||||
}
|
||||
@@ -647,7 +650,7 @@ pub async fn signal(
|
||||
) -> AppResult<Json<serde_json::Value>> {
|
||||
let (p, st) = guarded(&state, &user, &id).await?;
|
||||
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"));
|
||||
}
|
||||
if b.target_id == id {
|
||||
|
||||
@@ -230,6 +230,27 @@ async fn editing_is_not_voting() {
|
||||
.await;
|
||||
assert_eq!(s, StatusCode::CONFLICT, "{v}");
|
||||
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.
|
||||
let uid = unit(&doc, "E2")["id"].as_str().expect("u").to_owned();
|
||||
|
||||
@@ -4,11 +4,15 @@
|
||||
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 ou une relation
|
||||
change (« 50 » → « 5,0 », « 182.625 » → « 182 625 », « ≥ » → « ≤ », « + » → « - ») ;
|
||||
- **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) ;
|
||||
- **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,
|
||||
jamais, personne…) ; elle s'applique sans vote, garde les votes et les traductions ;
|
||||
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,
|
||||
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
|
||||
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.
|
||||
|
||||
@@ -76,13 +76,19 @@ 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 à 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'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
|
||||
changés (« 50 » → « 5,0 », « 182.625 » → « 182 625 », « ≥ » → « ≤ », « + » → « - », « % »)
|
||||
ou un mot de négation touché (ne, pas, jamais, personne…) rendent la modification « de fond ». Moteur : `crates/ld-engine/src/edit.rs`.
|
||||
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`.
|
||||
|
||||
### Deux articles qui se contredisent
|
||||
|
||||
|
||||
@@ -311,6 +311,7 @@ export const en: Messages = {
|
||||
'error.g1_not_configured': "Ğ1 sign-in isn't active yet.",
|
||||
'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.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.retry': 'Try again',
|
||||
|
||||
|
||||
@@ -311,6 +311,7 @@ export const es: Messages = {
|
||||
'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.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.retry': 'Reintentar',
|
||||
|
||||
|
||||
@@ -314,6 +314,7 @@ export const fr = {
|
||||
'error.g1_not_configured': "La connexion Ğ1 n'est pas encore active.",
|
||||
'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.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.retry': 'Réessayer',
|
||||
|
||||
|
||||
Reference in New Issue
Block a user