v3 : septième relecture — collages limités, titre jugé à part, découpage contrôlé

- Mots collés ou coupés : deux au plus, jamais sur une négation
  (« Si non » → « Sinon », « ne certifier » → « necertifier » sont du fond).
- Titre et corps jugés chacun pour soi, puis ensemble : le titre n'absorbe
  plus le début du corps.
- Caractères invisibles refusés aussi au découpage d'un brouillon et à la
  soumission.
- Tabulation et retour chariot ne sont plus des espaces libres ; les lettres
  latines rares (ǃ, Ʒ…) font partie de la forme.
- Le diff renvoyé après une coquille couvre le titre.

Boucle : 62 tests Rust, svelte-check, 9 vitest, e2e 12/12.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Yvv
2026-10-21 07:41:58 +02:00
co-authored by Claude Opus 5.5
parent fa8e8dc1f0
commit c3d6bf88b9
7 changed files with 120 additions and 31 deletions
+17 -9
View File
@@ -4,10 +4,13 @@
## Où on en est ## Où on en est
**Chantier 1 — Acte d'engagement Certification** : construit, boucle verte, prêt à livrer. **Chantier 1 — Acte d'engagement Certification** : **en ligne** sur decision.librodrome.org
depuis le 2026-10-21 (version `fa8e8dc`, pipeline Woodpecker n° 30). La maquette part sur
maquette.decision.librodrome.org. Sur la branche du chantier, les trois points laissés par la
septième relecture sont corrigés (pas encore en ligne).
- 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`, 60 tests dont 14 scénarios d'API et la - Boucle : Rust (fmt, clippy `-D warnings`, 62 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
@@ -17,7 +20,8 @@
sur maquette.decision.librodrome.org, seulement si le produit a réussi). 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 : - Relectures indépendantes avant mise en ligne, chacune traitée avant de livrer : `1f9c241 :
ok`, `94907af : ok`, puis quatre « à reprendre » (`ac9c904`, `9203fd2`, `1eac6f8`, `6c0754a`), 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 toutes sur la même question : une « coquille » (sans vote, publiée) qui change un sens, puis
`fa8e8dc : ok` (mis en ligne). La
règle est désormais simple et explicable : **une coquille touche les lettres et les espaces, 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 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 conteneurs en `restart: always`, vote réservé aux membres de la toile dans Monnaie libre (une
@@ -29,12 +33,16 @@
## Ce qui attend Yvv ## Ce qui attend Yvv
1. **Envoyer la demande à poka** : `docs/chantiers/acte-certification/DEMANDE-CONNECT.md`. 1. **Envoyer la demande à poka** : `docs/chantiers/acte-certification/DEMANDE-CONNECT.md`.
2. **Créer le dépôt des textes** sur git.duniter.org : `Yvv/libredecision-textes`, **public**, 2. **Créer le dépôt des textes**, public et vide : `Yvv/libredecision-textes` sur
vide ; y ajouter la **clé de déploiement** du serveur, **en écriture** (sa partie publique git.duniter.org (choix du formulaire, « là où regardent Cesium et Ğecko ») — question rouverte
s'affiche dans trustinWallet, `/acces`, section Publication). Le serveur fait lui-même le le 2026-10-21 : git.open.us.org à la place ? (une ligne, `LD_PUBLISH_REMOTE` dans
premier envoi. `docker/app-compose.yml`). Y ajouter la **clé de déploiement** du serveur, **en écriture** (sa
3. **Ouvrir trustinWallet** avec le lien administrateur initial (remis hors conversation), partie publique s'affiche dans trustinWallet, `/acces`, section Publication). Le serveur
puis créer l'**invitation de Chiara** (groupes Monnaie libre + groupe d'essai). réessaie toutes les 60 s et fait lui-même le premier envoi.
3. **Ouvrir trustinWallet** avec le lien administrateur initial (remis hors conversation, un
seul usage : il crée l'identité administratrice), puis créer l'**invitation de Chiara**
(groupes Monnaie libre + groupe d'essai). Question ouverte : un « lien d'équipe »
réutilisable (une identité par ouverture, jamais l'administration) ?
4. Donner à Chiara un compte **git.open.us.org** collaboratrice de `yvv/decision`. 4. Donner à Chiara un compte **git.open.us.org** collaboratrice de `yvv/decision`.
5. Décider : la **licence** du code ; le texte F1.2 du seed (annonce C = 0 et 30 jours). 5. Décider : la **licence** du code ; le texte F1.2 du seed (annonce C = 0 et 30 jours).
+48 -11
View File
@@ -80,15 +80,18 @@ fn skeleton(s: &str) -> String {
fold(s).chars().filter(|c| c.is_alphanumeric()).collect() fold(s).chars().filter(|c| c.is_alphanumeric()).collect()
} }
/// A letter of the Latin alphabet, precomposed accents included (after NFC): /// A letter of the Latin alphabet as French, English and Spanish write it,
/// the only letters a typo fix may touch. Any other script may hold look-alikes /// precomposed accents included (after NFC, Latin-1 and Latin Extended-A): the
/// (Cyrillic « а ») or reorder the display (Hebrew, Arabic). /// only letters a typo fix may touch. Any other letter may be a look-alike
/// (Cyrillic « а », « ǃ » for « ! », « Ʒ » for « 3 ») or reorder the display
/// (Hebrew, Arabic): it is kept as it is.
fn is_latin_letter(c: char) -> bool { fn is_latin_letter(c: char) -> bool {
c.is_alphabetic() && matches!(c as u32, 0x41..=0x5A | 0x61..=0x7A | 0xC0..=0x24F | 0x1E00..=0x1EFF) c.is_alphabetic() && matches!(c as u32, 0x41..=0x5A | 0x61..=0x7A | 0xC0..=0x17F)
} }
/// The only spaces free to change; any other (hair, thin, ogham…) is kept as it is. /// The only spaces free to change; any other (carriage return, tab, hair,
const FREE_SPACES: &[char] = &[' ', '\u{a0}', '\u{202f}', '\n', '\r', '\t']; /// thin, ogham…) is kept as it is.
const FREE_SPACES: &[char] = &[' ', '\u{a0}', '\u{202f}', '\n'];
/// Invisible format characters (zero-width, bidirectional overrides and /// Invisible format characters (zero-width, bidirectional overrides and
/// isolates, byte order mark): they can make "12" display as "21" ("Trojan /// isolates, byte order mark): they can make "12" display as "21" ("Trojan
@@ -196,20 +199,28 @@ fn words(s: &str) -> Vec<String> {
fold(s).split(|c: char| !c.is_alphanumeric()).filter(|w| !w.is_empty()).map(str::to_owned).collect() 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 /// Same letters, at most two words glued or split, never a negation:
/// transmission ». A boundary moved is not a typo (« les avoir » → « le savoir »). /// « latransmission » → « la transmission ». A boundary moved is not a typo
/// (« les avoir » → « le savoir »), nor « Si non » → « Sinon ».
fn only_glued_or_split(a: &[String], b: &[String]) -> bool { fn only_glued_or_split(a: &[String], b: &[String]) -> bool {
let (mut i, mut j) = (0, 0); let joined = |x: &String, y: &String| !NEGATIONS.contains(&x.as_str()) && !NEGATIONS.contains(&y.as_str());
let (mut i, mut j, mut moves) = (0, 0, 0);
while i < a.len() && j < b.len() { while i < a.len() && j < b.len() {
if a[i] == b[j] { if a[i] == b[j] {
(i, j) = (i + 1, j + 1); (i, j) = (i + 1, j + 1);
} else if j + 1 < b.len() && a[i] == format!("{}{}", b[j], b[j + 1]) { continue;
}
if j + 1 < b.len() && a[i] == format!("{}{}", b[j], b[j + 1]) && joined(&b[j], &b[j + 1]) {
(i, j) = (i + 1, j + 2); (i, j) = (i + 1, j + 2);
} else if i + 1 < a.len() && b[j] == format!("{}{}", a[i], a[i + 1]) { } else if i + 1 < a.len() && b[j] == format!("{}{}", a[i], a[i + 1]) && joined(&a[i], &a[i + 1]) {
(i, j) = (i + 2, j + 1); (i, j) = (i + 2, j + 1);
} else { } else {
return false; return false;
} }
moves += 1;
if moves > MAX_TYPO_WORDS {
return false;
}
} }
i == a.len() && j == b.len() i == a.len() && j == b.len()
} }
@@ -361,6 +372,32 @@ mod tests {
assert_eq!(classify(old, new), EditKind::Typographic); assert_eq!(classify(old, new), EditKind::Typographic);
} }
#[test]
fn glued_or_split_words_stay_few_and_never_touch_a_negation() {
// Seventh independent review, 2026-10-21: a text in force disfigured at will.
for (old, new) in [
("à ne certifier que", "à necertifier que"),
("à ne certifier que", "àne certifier que"),
("Si non, la demande attend", "Sinon, la demande attend"),
("la transmission de ce document à chacun", "latransmission dece documentà chacun"),
] {
assert_eq!(classify(old, new), EditKind::Substantive, "{old} → {new}");
}
assert_eq!(
classify("de latransmission dece document", "de la transmission de ce document"),
EditKind::Typographic
);
}
#[test]
fn odd_spaces_and_look_alike_letters_are_kept() {
assert_eq!(classify("un texte\tde référence", "un texte de référence"), EditKind::Substantive);
assert_eq!(classify("un texte\rde référence", "un texte de référence"), EditKind::Substantive);
assert_eq!(classify("toute certification", "toute certiǃfication"), EditKind::Substantive);
assert_eq!(classify("toute certification", "toute certifiƷcation"), EditKind::Substantive);
assert_eq!(classify("le coeur de l'ŒUVRE, niño", "le Coeur de l'œuvre, Niño"), EditKind::Typographic);
}
#[test] #[test]
fn accents_case_apostrophes_quotes() { fn accents_case_apostrophes_quotes() {
assert_eq!(classify("Je m'engage a verifier.", "Je m’engage à vérifier."), EditKind::Typographic); assert_eq!(classify("Je m'engage a verifier.", "Je m’engage à vérifier."), EditKind::Typographic);
+17 -6
View File
@@ -316,15 +316,20 @@ pub async fn edit(
} }
// Measured against the text that was voted, not the current one: successive // Measured against the text that was voted, not the current one: successive
// typo fixes are judged together and never add up to a change of meaning. // typo fixes are judged together and never add up to a change of meaning.
// Title and body are judged apart (a title never absorbs the start of the
// body) and together (two slips at most in all).
let next = format!("{title}\n{body}"); let next = format!("{title}\n{body}");
let kind = if next == format!("{}\n{}", p.title, p.body) { let kind = if title == p.title && body == p.body {
EditKind::Unchanged EditKind::Unchanged
} else { } else {
let (voted_title, voted_body) = voted_text(&state, &p).await?; let (voted_title, voted_body) = voted_text(&state, &p).await?;
match edit::classify(&format!("{voted_title}\n{voted_body}"), &next) { let kinds = [
EditKind::Unchanged => EditKind::Typographic, // back to the voted text edit::classify(&voted_title, title),
k => k, edit::classify(&voted_body, body),
} edit::classify(&format!("{voted_title}\n{voted_body}"), &next),
];
// All unchanged: back to the voted text, itself a correction.
if kinds.contains(&EditKind::Substantive) { EditKind::Substantive } else { EditKind::Typographic }
}; };
match kind { match kind {
EditKind::Unchanged => Ok(Json(json!({ "edit": "unchanged" }))), EditKind::Unchanged => Ok(Json(json!({ "edit": "unchanged" }))),
@@ -363,7 +368,7 @@ pub async fn edit(
if p.status == "in_force" { if p.status == "in_force" {
adoption::enqueue_publication(&state, &st.doc, "correction typographique").await?; adoption::enqueue_publication(&state, &st.doc, "correction typographique").await?;
} }
Ok(Json(json!({ "edit": "typo", "diff": edit::diff(&p.body, body) }))) Ok(Json(json!({ "edit": "typo", "diff": edit::diff(&format!("{}\n{}", p.title, p.body), &next) })))
} }
EditKind::Substantive => { EditKind::Substantive => {
if p.status == "in_force" { if p.status == "in_force" {
@@ -418,6 +423,9 @@ pub async fn submit(
if p.status != "draft" { if p.status != "draft" {
return Err(AppError::Conflict("not_draft")); return Err(AppError::Conflict("not_draft"));
} }
if edit::has_hidden_controls(&p.title) || edit::has_hidden_controls(&p.body) {
return Err(AppError::Invalid("hidden_characters"));
}
let now = util::now(); let now = util::now();
sqlx::query("UPDATE proposals SET status = 'open', submitted_at = ?, updated_at = ? WHERE id = ?") sqlx::query("UPDATE proposals SET status = 'open', submitted_at = ?, updated_at = ? WHERE id = ?")
.bind(&now) .bind(&now)
@@ -484,6 +492,9 @@ pub async fn split(
if parts.len() < 2 || parts.len() > 12 || parts.iter().any(|s| s.chars().count() > MAX_BODY) { if parts.len() < 2 || parts.len() > 12 || parts.iter().any(|s| s.chars().count() > MAX_BODY) {
return Err(AppError::Invalid("parts")); return Err(AppError::Invalid("parts"));
} }
if parts.iter().any(|s| edit::has_hidden_controls(s)) {
return Err(AppError::Invalid("hidden_characters"));
}
sqlx::query("UPDATE proposals SET body = ?, updated_at = ? WHERE id = ?") sqlx::query("UPDATE proposals SET body = ?, updated_at = ? WHERE id = ?")
.bind(parts[0]) .bind(parts[0])
.bind(util::now()) .bind(util::now())
+19
View File
@@ -251,6 +251,17 @@ async fn editing_is_not_voting() {
) )
.await; .await;
assert_eq!((s, v["error"].as_str()), (StatusCode::BAD_REQUEST, Some("hidden_characters"))); assert_eq!((s, v["error"].as_str()), (StatusCode::BAD_REQUEST, Some("hidden_characters")));
// The title never absorbs the start of the body.
let title = e2["title"].as_str().expect("title");
let (head, rest) = body.split_once(' ').expect("two words");
let (s, v) = b
.call(
"PATCH",
&format!("/api/proposals/{e2_id}"),
Some(json!({ "title": format!("{title} {head}"), "body": rest })),
)
.await;
assert_eq!((s, v["error"].as_str()), (StatusCode::CONFLICT, Some("substantive_in_force")), "{v}");
// Typo fixes are measured against the voted text: slips never add up. // Typo fixes are measured against the voted text: slips never add up.
let patch = |body: String| json!({ "title": e2["title"], "body": body }); let patch = |body: String| json!({ "title": e2["title"], "body": body });
let (_, v) = b let (_, v) = b
@@ -396,6 +407,14 @@ async fn split_a_draft() {
.call("POST", &format!("/api/docs/{id}/proposals"), Some(json!({ "title": "Deux choses", "body": "a\n\nb" }))) .call("POST", &format!("/api/docs/{id}/proposals"), Some(json!({ "title": "Deux choses", "body": "a\n\nb" })))
.await; .await;
let p = v["id"].as_str().expect("id").to_owned(); let p = v["id"].as_str().expect("id").to_owned();
let (s, v) = a
.call(
"POST",
&format!("/api/proposals/{p}/split"),
Some(json!({ "parts": ["Première.", "Au \u{202e}01\u{202c}."] })),
)
.await;
assert_eq!((s, v["error"].as_str()), (StatusCode::BAD_REQUEST, Some("hidden_characters")));
let (s, v) = let (s, v) =
a.call("POST", &format!("/api/proposals/{p}/split"), Some(json!({ "parts": ["Première.", "Seconde."] }))).await; a.call("POST", &format!("/api/proposals/{p}/split"), Some(json!({ "parts": ["Première.", "Seconde."] }))).await;
assert_eq!(s, StatusCode::OK, "{v}"); assert_eq!(s, StatusCode::OK, "{v}");
+8 -2
View File
@@ -12,7 +12,9 @@ ou de fond), jamais contre la correction précédente :
comptent pour un même signe) ; comptent pour un même signe) ;
- les adresses, les identifiants qui contiennent un chiffre et le contenu entre accents graves - les adresses, les identifiants qui contiennent un chiffre et le contenu entre accents graves
restent identiques à la lettre ; restent identiques à la lettre ;
- les espaces ne font que coller ou couper des mots, jamais déplacer une frontière ; - les espaces (espace, insécable, fine insécable, saut de ligne) ne font que coller ou couper
des mots, deux au plus, jamais sur une négation, jamais en déplaçant une frontière ;
- le titre et le corps sont jugés chacun pour soi, puis ensemble ;
- au plus deux mots changent, chacun d'un seul glissement de clavier au cœur du mot (une lettre - 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 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 lettres ; jamais une lettre remplacée ; jamais sur un mot de négation, de quantité ou de
@@ -28,7 +30,11 @@ donne un conflit. Les brouillons s'éditent librement.
près ». Six relectures indépendantes (2026-10-21) ont trouvé, l'une après l'autre, des 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 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 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. devant un nombre en gras. D'où le passage d'une liste noire à cette règle. La septième
(`fa8e8dc : ok`) a laissé trois points, corrigés aussitôt : mots collés ou coupés sans limite
(« Si non » → « Sinon »), titre jugé avec le corps (le titre en absorbait le début), caractères
invisibles non contrôlés au découpage d'un brouillon ; et deux remarques : tabulation et retour
chariot ne sont plus des espaces libres, les lettres latines rares font partie de la forme.
**Coût.** Une ponctuation oubliée, une emphase, un pluriel mal accordé demandent une **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 proposition (ou une révision par l'auteur). On préfère ce sens d'erreur : un texte de référence
+10 -3
View File
@@ -95,12 +95,19 @@ change la portée d'une exception. La règle est donc construite à l'envers d'u
- la **forme** du texte (tout ce qui n'est ni lettre latine ni espace, dans l'ordre : ponctuation, - 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 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 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 ; « 15 ») ; les apostrophes et guillemets de tous styles comptent pour un même signe ; « lettre
latine » s'entend de celles du français, de l'anglais et de l'espagnol (les lettres latines
rares qui imitent un signe, « ǃ » ou « Ʒ », font partie de la forme) ; « espace » s'entend de
l'espace, de l'insécable, de la fine insécable et du saut de ligne (ni tabulation ni retour
chariot) ;
- les **adresses** (liens, domaines, courriels) et les **identifiants qui contiennent un - 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 chiffre** (clés publiques, références) sont comparés à l'identique, casse comprise ; le contenu
entre accents graves aussi ; entre accents graves aussi ;
- les espaces ne font que **coller ou couper** des mots, jamais déplacer une frontière (« les - les espaces ne font que **coller ou couper** des mots, deux au plus, jamais sur un mot de
avoir » → « le savoir » est du fond) ; négation (« Si non » → « Sinon », « ne certifier » → « necertifier » sont du fond), et jamais
déplacer une frontière (« les avoir » → « le savoir » est du fond) ;
- le **titre** et le **corps** sont jugés chacun pour soi (un titre n'absorbe pas le début du
corps), puis ensemble (deux mots au plus en tout) ;
- un **glissement** = une lettre ajoutée, retirée, ou deux voisines inversées, jamais aux deux - 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 premières ni aux deux dernières lettres (préfixes « valide » → « invalide », terminaisons
« certifierai » → « certifierais »), jamais une lettre remplacée (« deux » → « dix »), jamais sur « certifierai » → « certifierais »), jamais une lettre remplacée (« deux » → « dix »), jamais sur
@@ -42,3 +42,4 @@ page, la langue et la taille de l'écran. Les retours se traitent dans trustinWa
|---|---|---|---|---| |---|---|---|---|---|
| 2026-10-21 | automate | local, serveur jetable :8092 | C1–C10, M1 | 11 / 11 verts | | 2026-10-21 | automate | local, serveur jetable :8092 | C1–C10, M1 | 11 / 11 verts |
| 2026-10-21 | automate | local, après relecture indépendante | C1–C11, M1 | 12 / 12 verts | | 2026-10-21 | automate | local, après relecture indépendante | C1–C11, M1 | 12 / 12 verts |
| 2026-10-21 | automate | local, après la septième relecture (corrections) | C1–C11, M1 | 12 / 12 verts |