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
|
- /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
@@ -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
@@ -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);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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',
|
||||||
|
|
||||||
|
|||||||
@@ -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',
|
||||||
|
|
||||||
|
|||||||
@@ -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',
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user