v3 : sixième relecture — une coquille touche les lettres et les espaces, rien d'autre

- La forme du texte (tout ce qui n'est ni lettre latine ni espace, dans
  l'ordre, avec les espaces qui touchent un chiffre) ne bouge plus : ponctuation,
  emphase, signes, parenthèses passent par une proposition (un signe moins devant
  un nombre en gras, deux conditions fusionnées, une parenthèse déplacée).
  Apostrophes et guillemets de tous styles comptent pour un même signe.
- Les espaces ne font que coller ou couper des mots, jamais déplacer une
  frontière (« les avoir » → « le savoir »).
- Identifiants qui contiennent un chiffre (clés, références) comparés à
  l'identique, casse comprise, comme les adresses.
- Correction écrite seulement sur le texte jugé (révision, statut, texte) :
  sinon conflit « changed » — plus de course avec une entrée en vigueur.
- Déploiement : « previous » n'est pas repris d'un pipeline annulé.
- Interface (fr/en/es) : la note de correction dit la règle telle qu'elle est.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Yvv
2026-10-21 06:52:04 +02:00
co-authored by Claude Opus 5.5
parent 6c0754a5c3
commit fa8e8dc1f0
9 changed files with 218 additions and 130 deletions
+4 -1
View File
@@ -101,9 +101,12 @@ steps:
- DOMAIN=decision.librodrome.org
# La version en service (celle qui tourne, validée par son propre healthcheck) est
# gardée sous « previous » : « retablir » la relance si ce déploiement échoue.
# Un témoin resté d'un pipeline annulé : ce qui tourne n'a pas été validé, on garde l'ancien « previous ».
- |
IMG=$(docker inspect --format '{{if .State.Running}}{{.Image}}{{end}}' lpk-libredecision-app 2>/dev/null || true)
if [ -n "$IMG" ]; then docker tag "$IMG" libredecision-app:previous && echo "Version en service gardee ($IMG)"; fi
if [ -n "$IMG" ] && [ ! -e /opt/libredecision-app/deploying ]; then
docker tag "$IMG" libredecision-app:previous && echo "Version en service gardee ($IMG)"
fi
# 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
+10 -11
View File
@@ -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`, 59 tests dont 14 scénarios d'API et la
- Boucle : Rust (fmt, clippy `-D warnings`, 60 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
@@ -16,16 +16,15 @@
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).
- Relectures indépendantes avant mise en ligne, chacune traitée avant de livrer : `1f9c241 :
ok`, `94907af : ok`, `ac9c904 : à reprendre`, `9203fd2 : à reprendre`, `1eac6f8 : à reprendre`. Les trois dernières ont
montré qu'une « coquille » (sans vote, publiée) pouvait encore changer un sens — mots
retournés, caractères invisibles, sosies de chiffres, corrections enchaînées, adresses. La
règle est désormais une liste blanche mesurée contre le texte voté (voir CHANTIER.md). Les
autres points : ordre des workflows, anciens conteneurs en
`restart: always`, vote réservé aux membres de la toile dans Monnaie libre (une personne,
une voix), lien d'invitation jamais vers une identité Ğ1, réservation du lien avant usage,
un nombre n'est jamais une coquille, redirection après connexion, en-têtes anti-encadrement,
cookies préfixés `__Host-`, retour arrière automatique du déploiement (maquette au premier,
version précédente ensuite), syft épinglé.
ok`, `94907af : ok`, puis quatre « à reprendre » (`ac9c904`, `9203fd2`, `1eac6f8`, `6c0754a`),
toutes sur la même question : une « coquille » (sans vote, publiée) qui change un sens. La
règle est désormais simple et explicable : **une coquille touche les lettres et les espaces,
rien d'autre** (CHANTIER.md, ADR 0003). Autres points traités : 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, redirection après connexion, en-têtes anti-encadrement, cookies `__Host-`, retour
arrière automatique du déploiement (maquette au premier, version précédente ensuite), courses
entre correction et entrée en vigueur, syft épinglé.
## Ce qui attend Yvv
+97 -60
View File
@@ -3,12 +3,12 @@
//! Once a proposal is under vote, its author may still correct it. The engine
//! tells two kinds of corrections apart:
//!
//! - **typographic**: accents, case, punctuation, spacing, emphasis, or at most
//! 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.
//! - **typographic**: letters and spaces only — accents, case, the style of
//! apostrophes and quotes, words glued or split, or at most two misspelt
//! words fixed by a single slip each (one letter added, removed or swapped
//! in the middle of the word, never on a negation or a quantity). Never a
//! punctuation mark, a figure, a sign, an emphasis, a formula or an address.
//! 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.
//!
@@ -87,13 +87,8 @@ fn is_latin_letter(c: char) -> bool {
c.is_alphabetic() && matches!(c as u32, 0x41..=0x5A | 0x61..=0x7A | 0xC0..=0x24F | 0x1E00..=0x1EFF)
}
/// Ordinary punctuation, free to change in a typo fix — except where noted below.
const PUNCTUATION: &[char] =
&['.', ',', ';', ':', '!', '?', '\'', '’', '‘', '"', '“', '”', '«', '»', '…', '‐', '-', '–', '—', '*', '_'];
/// The only spaces free to change; any other (hair, thin, ogham…) is kept as it is.
const FREE_SPACES: &[char] = &[' ', '\u{a0}', '\u{202f}', '\n', '\r', '\t'];
/// Dashes become a sign when a figure follows or precedes, even across spaces ("- 5").
const DASHES: &[char] = &['‐', '-', '–', '—'];
/// Invisible format characters (zero-width, bidirectional overrides and
/// isolates, byte order mark): they can make "12" display as "21" ("Trojan
@@ -104,42 +99,43 @@ pub fn has_hidden_controls(s: &str) -> bool {
)
}
/// Everything a typo fix may not change, in order. Built the other way round
/// from a list of dangerous symbols: what is kept is everything that is NOT a
/// Latin letter, a space or ordinary punctuation — figures in any script, signs,
/// relations, brackets, look-alikes (U+201A for a comma, U+2044 for a slash,
/// full-width forms), stray combining marks (U+0336, U+0338), other alphabets.
/// Anything that touches a figure counts too, spaces and punctuation included:
/// "5,0", "5,\u{202f}0", "1:5", "1…5" never read like "50" or "15".
fn signature(s: &str) -> String {
/// Apostrophes and quotation marks of every style are one and the same sign:
/// « Je m'engage » → « Je m’engage » is typography.
fn same_mark(c: char) -> char {
match c {
'’' | '‘' | 'ʼ' => '\'',
'“' | '”' | '«' | '»' => '"',
other => other,
}
}
/// The shape of a text: every run of Latin letters becomes one mark, free spaces
/// vanish, and **everything else stays as it is, in order** — punctuation,
/// figures, signs, emphasis, brackets, look-alikes, other alphabets. A typo fix
/// keeps the shape: it touches letters and spaces only (2026-10-21, after six
/// independent reviews showed that any free punctuation can carry a meaning:
/// a minus sign before a bold number, a list merged into one condition…).
fn shape(s: &str) -> String {
let cs: Vec<char> = s.nfc().collect();
let is_figure = |i: Option<usize>| i.and_then(|i| cs.get(i)).is_some_and(|c| c.is_numeric());
// The next character that is not a free space, on either side.
let across = |i: usize, step: isize| {
let mut j = i as isize + step;
while j >= 0 && (j as usize) < cs.len() && FREE_SPACES.contains(&cs[j as usize]) {
j += step;
}
j >= 0 && is_figure(Some(j as usize))
};
cs.iter()
.enumerate()
.filter(|&(i, &c)| {
let touches_figure = is_figure(i.checked_sub(1)) || is_figure(Some(i + 1));
if is_latin_letter(c) {
false
} else if touches_figure {
true
} else if FREE_SPACES.contains(&c) {
false
} else if DASHES.contains(&c) {
across(i, -1) || across(i, 1)
} else {
!PUNCTUATION.contains(&c)
let mut out = String::new();
let mut in_word = false;
for (i, &c) in cs.iter().enumerate() {
if is_latin_letter(c) {
if !in_word {
out.push('a');
}
})
.map(|(_, &c)| c)
.collect()
in_word = true;
} else if FREE_SPACES.contains(&c) && !is_figure(i.checked_sub(1)) && !is_figure(Some(i + 1)) {
// A space between letters may come and go: « latransmission » and
// « la transmission » share a shape (segmentation is checked apart).
// Next to a figure it stays: « 1 5 » is not « 15 ».
} else {
out.push(same_mark(c));
in_word = false;
}
}
out
}
/// What stands between backticks: formulas and identifiers, compared exactly.
@@ -147,15 +143,16 @@ fn code_spans(s: &str) -> Vec<&str> {
s.split('`').skip(1).step_by(2).collect()
}
/// Web and mail addresses, domains, paths: compared exactly ("duniter" /
/// "dunniter" is a typo of a word, not of an address).
fn addresses(s: &str) -> Vec<String> {
/// Addresses (links, domains, mails, paths) and identifiers that hold a figure
/// (keys, fingerprints, references): compared exactly, case included.
fn exact_tokens(s: &str) -> Vec<String> {
s.split_whitespace()
.map(|t| t.trim_matches(|c: char| !c.is_alphanumeric()))
.filter(|t| {
let cs: Vec<char> = t.chars().collect();
t.contains("://")
|| t.contains('@')
|| cs.iter().any(|c| c.is_numeric()) && cs.iter().any(|c| c.is_alphabetic())
|| (1..cs.len().saturating_sub(1))
.any(|i| matches!(cs[i], '.' | '/') && cs[i - 1].is_alphanumeric() && cs[i + 1].is_alphanumeric())
})
@@ -199,6 +196,24 @@ fn words(s: &str) -> Vec<String> {
fold(s).split(|c: char| !c.is_alphanumeric()).filter(|w| !w.is_empty()).map(str::to_owned).collect()
}
/// Same letters, words only glued or split: « latransmission » → « la
/// transmission ». A boundary moved is not a typo (« les avoir » → « le savoir »).
fn only_glued_or_split(a: &[String], b: &[String]) -> bool {
let (mut i, mut j) = (0, 0);
while i < a.len() && j < b.len() {
if a[i] == b[j] {
(i, j) = (i + 1, j + 1);
} else if j + 1 < b.len() && a[i] == format!("{}{}", b[j], b[j + 1]) {
(i, j) = (i + 1, j + 2);
} else if i + 1 < a.len() && b[j] == format!("{}{}", a[i], a[i + 1]) {
(i, j) = (i + 2, j + 1);
} else {
return false;
}
}
i == a.len() && j == b.len()
}
/// Classify `new` against `old`. For a text under vote, `old` must be the text
/// that was **voted** (its last initial or substantive revision), not the
/// current one: typo fixes are measured together, so slips never add up.
@@ -207,16 +222,17 @@ pub fn classify(old: &str, new: &str) -> EditKind {
return EditKind::Unchanged;
}
if has_hidden_controls(new)
|| signature(old) != signature(new)
|| shape(old) != shape(new)
|| code_spans(old) != code_spans(new)
|| addresses(old) != addresses(new)
|| exact_tokens(old) != exact_tokens(new)
{
return EditKind::Substantive;
}
if skeleton(old) == skeleton(new) {
return EditKind::Typographic;
}
let (a, b) = (words(old), words(new));
if skeleton(old) == skeleton(new) {
// Accents, case, apostrophes; words glued or split, never re-cut.
return if only_glued_or_split(&a, &b) { EditKind::Typographic } else { EditKind::Substantive };
}
if a.len() != b.len() {
return EditKind::Substantive;
}
@@ -346,12 +362,36 @@ mod tests {
}
#[test]
fn accents_case_punctuation_emphasis() {
assert_eq!(classify("Je m'engage a verifier.", "Je m’engage à vérifier !"), EditKind::Typographic);
assert_eq!(classify("un **être humain**", "un être humain"), EditKind::Typographic);
fn accents_case_apostrophes_quotes() {
assert_eq!(classify("Je m'engage a verifier.", "Je m’engage à vérifier."), EditKind::Typographic);
assert_eq!(classify("le \"Pacte\" de la monnaie", "le « Pacte » de la Monnaie"), EditKind::Typographic);
assert_eq!(classify("same", "same"), EditKind::Unchanged);
}
#[test]
fn punctuation_and_emphasis_go_through_a_proposal() {
// Sixth independent review: free punctuation can always carry a meaning.
for (old, new) in [
("Je m'engage à vérifier.", "Je m'engage à vérifier !"),
("Je m'engage à vérifier.", "Je m'engage à vérifier ?"),
("un **être humain**", "un être humain"),
("tous les **5 jours**", "tous les -**5 jours**"),
("Seuil = C + B^W", "Seuil = C + B^-W"),
("max(0, T-C)", "max(0, TC)"),
("(Y = ceil(N))", "(Y = -ceil(N))"),
("- avoir un compte\n- être joignable", "- avoir un compte, être joignable"),
("(sauf le cas a) et le cas b", "(sauf le cas a et le cas b)"),
("libre - le texte", "libre — le texte"),
("- une liste", "* une liste"),
("clé DsEx1pS33abc", "clé dSeX1Ps33abc"),
("les avoir vérifiés", "le savoir vérifiés"),
] {
assert_eq!(classify(old, new), EditKind::Substantive, "{old} → {new}");
}
assert_eq!(classify("toute latransmission", "toute la transmission"), EditKind::Typographic);
assert_eq!(classify("la trans mission", "la transmission"), EditKind::Typographic);
}
#[test]
fn a_figure_a_sign_or_a_relation_is_never_a_typo() {
// Same letters, different meaning (independent reviews, 2026-10-21).
@@ -369,13 +409,10 @@ mod tests {
] {
assert_eq!(classify(old, new), EditKind::Substantive, "{old} → {new}");
}
// 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("50 voix pour", "50 voix Pour"), 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("1. premier point", "1. Premier point"), EditKind::Typographic);
}
#[test]
+38 -15
View File
@@ -333,13 +333,25 @@ pub async fn edit(
if !(is_author || p.origin == "seed" || p.status == "in_force") {
return Err(AppError::Forbidden);
}
sqlx::query("UPDATE proposals SET title = ?, body = ?, updated_at = ? WHERE id = ?")
.bind(title)
.bind(body)
.bind(&now)
.bind(&id)
.execute(&state.db)
.await?;
// Written only over the text that was judged: an entry into force or another
// correction in between makes it a conflict, never a silent change.
let done = sqlx::query(
"UPDATE proposals SET title = ?, body = ?, updated_at = ?
WHERE id = ? AND revision = ? AND status = ? AND title = ? AND body = ?",
)
.bind(title)
.bind(body)
.bind(&now)
.bind(&id)
.bind(p.revision)
.bind(&p.status)
.bind(&p.title)
.bind(&p.body)
.execute(&state.db)
.await?;
if done.rows_affected() != 1 {
return Err(AppError::Conflict("changed"));
}
record_revision(&state, &id, p.revision, "typo", title, body, Some(&user.id)).await?;
// A typo fix does not change the meaning: up-to-date translations stay up to date.
sqlx::query("UPDATE translations SET source_hash = ? WHERE proposal_id = ? AND source_hash = ?")
@@ -361,14 +373,25 @@ pub async fn edit(
return Err(AppError::Conflict("substantive_not_author"));
}
let revision = p.revision + 1;
sqlx::query("UPDATE proposals SET title = ?, body = ?, revision = ?, updated_at = ? WHERE id = ?")
.bind(title)
.bind(body)
.bind(revision)
.bind(&now)
.bind(&id)
.execute(&state.db)
.await?;
// Only on the open proposal that was read: never on a text that entered into
// force in the meantime.
let done = sqlx::query(
"UPDATE proposals SET title = ?, body = ?, revision = ?, updated_at = ?
WHERE id = ? AND revision = ? AND status = 'open' AND title = ? AND body = ?",
)
.bind(title)
.bind(body)
.bind(revision)
.bind(&now)
.bind(&id)
.bind(p.revision)
.bind(&p.title)
.bind(&p.body)
.execute(&state.db)
.await?;
if done.rows_affected() != 1 {
return Err(AppError::Conflict("changed"));
}
record_revision(&state, &id, revision, "substantive", title, body, Some(&user.id)).await?;
// Signals were about the former text: they lapse, their authors may signal again.
sqlx::query(
+30 -23
View File
@@ -1,28 +1,35 @@
# 0003 — Corriger n'est pas voter
**Contexte.** « Ça ne peut pas devenir un système qui nécessite un vote pour corriger une
coquille. » Mais une correction ne doit pas pouvoir changer le sens d'un texte voté.
coquille. » Mais une correction ne doit pas pouvoir changer le sens d'un texte voté — et tout
membre peut corriger un texte en vigueur, qui est republié aussitôt.
**Décision.** Le moteur classe chaque modification (`ld-engine/src/edit.rs`), toujours
**contre le texte voté** (sa dernière révision initiale ou de fond) — jamais contre la dernière
correction, pour que les glissements ne s'additionnent pas :
- **jamais coquille** si change quoi que ce soit d'autre que des lettres latines, des espaces
et la ponctuation ordinaire (liste blanche : chiffres de toute écriture, signes, relations,
parenthèses, sosies, marques superposées, autres alphabets restent figés), ni une adresse, ni
le contenu entre accents graves, ni rien de ce qui touche un chiffre (espaces et ponctuation
comprises), ni un tiret devant ou après un chiffre même à travers des espaces ; seules quatre
espaces sont libres (ordinaire, insécable, fine insécable, saut de ligne) ;
- **coquille** si le squelette (sans accents, casse, ponctuation, espaces) est identique, ou si
au plus 2 mots diffèrent chacun d'un seul glissement de clavier au cœur du mot — une lettre
ajoutée, retirée ou deux voisines inversées, jamais aux deux premières lettres (préfixes) ni
aux deux dernières (nombre, genre, temps, mode), jamais une lettre remplacée, jamais sur un mot
de négation, de quantité ou de position ; elle s'applique sans vote,
garde les votes et les traductions ;
- les caractères invisibles de mise en forme (inversion du sens d'écriture, largeur nulle) sont
refusés partout où l'on écrit un texte ;
- **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.
**Décision.** Une coquille touche **les lettres et les espaces, rien d'autre**. Le moteur
(`ld-engine/src/edit.rs`) la juge toujours **contre le texte voté** (dernière révision initiale
ou de fond), jamais contre la correction précédente :
- la **forme** du texte — tout ce qui n'est ni lettre latine ni espace, dans l'ordre, avec les
espaces qui touchent un chiffre — reste identique (apostrophes et guillemets de tous styles
comptent pour un même signe) ;
- les adresses, les identifiants qui contiennent un chiffre et le contenu entre accents graves
restent identiques à la lettre ;
- les espaces ne font que coller ou couper des mots, jamais déplacer une frontière ;
- au plus deux mots changent, chacun d'un seul glissement de clavier au cœur du mot (une lettre
ajoutée, retirée ou deux voisines inversées ; jamais aux deux premières ni aux deux dernières
lettres ; jamais une lettre remplacée ; jamais sur un mot de négation, de quantité ou de
position) ;
- les caractères invisibles de mise en forme sont refusés partout où l'on écrit.
**Coût.** Le classement est prudent : certaines vraies coquilles (« persone » → « personne »)
passent pour du fond et demandent une proposition. On préfère ce sens d'erreur.
Sinon c'est **du fond** : nouvelle révision d'une proposition ouverte (par son auteur), votes
antérieurs « à reconfirmer », signalements levés ; refusé sur un texte en vigueur ou de départ.
L'écriture ne vaut que sur le texte jugé : un texte entré en vigueur ou changé entre-temps
donne un conflit. Les brouillons s'éditent librement.
**Historique.** La première version tolérait la ponctuation et deux mots « à deux lettres
près ». Six relectures indépendantes (2026-10-21) ont trouvé, l'une après l'autre, des
coquilles qui changeaient le sens : mots retournés (« valide » → « invalide »), caractères
invisibles, sosies de chiffres, corrections enchaînées, adresse typosquattée, signe moins
devant un nombre en gras. D'où le passage d'une liste noire à cette règle.
**Coût.** Une ponctuation oubliée, une emphase, un pluriel mal accordé demandent une
proposition (ou une révision par l'auteur). On préfère ce sens d'erreur : un texte de référence
publié vaut plus qu'un point final.
+33 -17
View File
@@ -74,28 +74,44 @@ l'inertie du cran au-dessus** (« inertie + ») : alourdir ou alléger demande p
### Corriger n'est pas voter
**Une coquille touche les lettres et les espaces, rien d'autre.** Accents, majuscules, le style
des apostrophes et des guillemets, un mot collé ou coupé (« latransmission » → « la
transmission »), ou au plus deux mots mal tapés — chacun un seul glissement de clavier au cœur du
mot (« certifcation » → « certification »). Tout le reste passe par une proposition : la
ponctuation, les chiffres, les signes, l'emphase, les formules, les adresses, et tout changement
de sens.
| Geste | Qui | Effet sur les votes |
|---|---|---|
| Corriger une **coquille** (accents, casse, ponctuation, espaces ; ou jusqu'à 2 mots mal tapés, une lettre ajoutée, retirée ou inversée chacun, au cœur du mot) — mesurée contre le texte **voté**, jamais contre la dernière correction | tout membre sur un texte de départ ou en vigueur ; l'auteur sur sa proposition | aucun — les votes restent, les traductions restent à jour |
| Corriger une **coquille** | 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 — quatre relectures indépendantes l'ont éprouvé, et il fonctionne
**par liste blanche** : une coquille ne peut toucher que des lettres latines, des espaces et la
ponctuation ordinaire. Tout le reste compte comme le sens et ne bouge pas : chiffres (de toute
écriture), signes, relations, parenthèses, sosies (« ‚ » pour une virgule, « ⁄ » pour une barre,
formes pleine chasse), marques superposées (rayure, barre), autres alphabets (cyrillique,
hébreu), rayure Markdown, tout ce qui est entre accents graves, les **adresses** (domaines,
liens, courriels), **tout ce qui touche un chiffre**, espaces et ponctuation comprises (« 50 » →
« 5,0 », « 5, 0 », « 1:5 » ; « 182.625 » → « 182 625 »), et un tiret devant ou après un chiffre,
même séparé par des espaces (« - 5 »). Seules quatre espaces sont libres (ordinaire,
insécable, fine insécable, saut de ligne) : les autres (fine, ultra-fine…) sont figées. Pour les mots : au plus deux mots, chacun **un seul
glissement de clavier au cœur du mot** — jamais aux deux premières ni aux deux dernières lettres
(préfixes « valide » → « invalide », terminaisons « certifierai » → « certifierais »), jamais
une lettre remplacée (« deux » → « dix »), jamais un mot de négation, de quantité ou de position
(ne, jamais, deux, majorité, tous, dessus, avant…). Chaque correction est mesurée contre le
**texte voté** : les glissements ne s'additionnent pas d'une correction à l'autre. Les caractères
invisibles (inversion du sens d'écriture, largeur nulle) sont refusés partout. Moteur : `crates/ld-engine/src/edit.rs`.
Pourquoi si strict : six relectures indépendantes ont montré qu'une ponctuation laissée libre
finit toujours par porter un sens — un signe moins devant un nombre en gras, deux conditions
d'une liste fusionnées en une, une virgule glissée dans « 50 », une parenthèse déplacée qui
change la portée d'une exception. La règle est donc construite à l'envers d'une liste noire :
- la **forme** du texte (tout ce qui n'est ni lettre latine ni espace, dans l'ordre : ponctuation,
chiffres de toute écriture, signes, emphase, parenthèses, sosies, marques superposées, autres
alphabets) ne bouge pas ; une espace à côté d'un chiffre en fait partie (« 1 5 » n'est pas
« 15 ») ; les apostrophes et guillemets de tous styles comptent pour un même signe ;
- les **adresses** (liens, domaines, courriels) et les **identifiants qui contiennent un
chiffre** (clés publiques, références) sont comparés à l'identique, casse comprise ; le contenu
entre accents graves aussi ;
- les espaces ne font que **coller ou couper** des mots, jamais déplacer une frontière (« les
avoir » → « le savoir » est du fond) ;
- un **glissement** = une lettre ajoutée, retirée, ou deux voisines inversées, jamais aux deux
premières ni aux deux dernières lettres (préfixes « valide » → « invalide », terminaisons
« certifierai » → « certifierais »), jamais une lettre remplacée (« deux » → « dix »), jamais sur
un mot de négation, de quantité ou de position (ne, jamais, deux, majorité, tous, dessus…) ;
- chaque correction est mesurée contre le **texte voté**, jamais contre la correction
précédente : les glissements ne s'additionnent pas ;
- les caractères invisibles (inversion du sens d'écriture, largeur nulle) sont refusés partout ;
- une correction ne s'écrit que sur le texte qui a été jugé : si le texte est entré en vigueur
ou a changé entre-temps, c'est un conflit (« recharge, puis recommence »).
Moteur : `crates/ld-engine/src/edit.rs` ; règle d'écriture : `crates/ld-server/src/proposals.rs`.
### Deux articles qui se contredisent
+2 -1
View File
@@ -198,7 +198,7 @@ export const en: Messages = {
'history.by': 'by {name}',
'typo.title': 'Fix a typo',
'typo.note': 'Accents, punctuation, typos: the fix applies without a vote, with its author and its diff. A change in meaning goes through a proposal.',
'typo.note': 'Accents, capitals, typos, words run together or split: the fix applies without a vote, with its author and its diff. Punctuation, figures, formulas, addresses and any change in meaning go through a proposal.',
'typo.save': 'Save the fix',
'typo.done': 'Typo fixed, votes untouched.',
'typo.substantive': 'This edit affects the meaning: propose it as a change.',
@@ -312,6 +312,7 @@ export const en: Messages = {
'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.changed': 'The text has changed in the meantime: reload it, then try again.',
'error.wot_members_only': 'Here, only members of the Ğ1 web of trust vote.',
'error.retry': 'Try again',
+2 -1
View File
@@ -198,7 +198,7 @@ export const es: Messages = {
'history.by': 'por {name}',
'typo.title': 'Corregir una errata',
'typo.note': 'Acentos, puntuación, erratas: la corrección se aplica sin votación, con su autor y su diff. Un cambio de sentido pasa por una propuesta.',
'typo.note': 'Acentos, mayúsculas, erratas, palabras pegadas o separadas: la corrección se aplica sin votación, con su autor y su diff. La puntuación, las cifras, las fórmulas, las direcciones y todo cambio de sentido pasan por una propuesta.',
'typo.save': 'Guardar la corrección',
'typo.done': 'Errata corregida, sin tocar los votos.',
'typo.substantive': 'Este cambio afecta al sentido: proponlo como modificación.',
@@ -312,6 +312,7 @@ export const es: Messages = {
'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.changed': 'El texto ha cambiado mientras tanto: vuelve a cargarlo y empieza de nuevo.',
'error.wot_members_only': 'Aquí solo votan los miembros de la red de confianza Ğ1.',
'error.retry': 'Reintentar',
+2 -1
View File
@@ -201,7 +201,7 @@ export const fr = {
'history.by': 'par {name}',
'typo.title': 'Corriger une coquille',
'typo.note': "Accents, ponctuation, faute de frappe : la correction s'applique sans vote, avec son auteur et son diff. Un changement de sens passe par une proposition.",
'typo.note': "Accents, majuscules, faute de frappe, mot collé ou coupé : la correction s'applique sans vote, avec son auteur et son diff. La ponctuation, les chiffres, les formules, les adresses et tout changement de sens passent par une proposition.",
'typo.save': 'Enregistrer la correction',
'typo.done': 'Coquille corrigée, sans toucher aux votes.',
'typo.substantive': 'Ce changement touche au sens : propose-le comme modification.',
@@ -315,6 +315,7 @@ export const fr = {
'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.changed': 'Le texte a changé entre-temps : recharge-le, puis recommence.',
'error.wot_members_only': 'Ici, seuls les membres de la toile de confiance Ğ1 votent.',
'error.retry': 'Réessayer',