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
+48 -11
View File
@@ -80,15 +80,18 @@ fn skeleton(s: &str) -> String {
fold(s).chars().filter(|c| c.is_alphanumeric()).collect()
}
/// A letter of the Latin alphabet, precomposed accents included (after NFC):
/// the only letters a typo fix may touch. Any other script may hold look-alikes
/// (Cyrillic « а ») or reorder the display (Hebrew, Arabic).
/// A letter of the Latin alphabet as French, English and Spanish write it,
/// precomposed accents included (after NFC, Latin-1 and Latin Extended-A): the
/// 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 {
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.
const FREE_SPACES: &[char] = &[' ', '\u{a0}', '\u{202f}', '\n', '\r', '\t'];
/// The only spaces free to change; any other (carriage return, tab, hair,
/// thin, ogham…) is kept as it is.
const FREE_SPACES: &[char] = &[' ', '\u{a0}', '\u{202f}', '\n'];
/// Invisible format characters (zero-width, bidirectional overrides and
/// 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()
}
/// Same letters, words only glued or split: « latransmission » → « la
/// transmission ». A boundary moved is not a typo (« les avoir » → « le savoir »).
/// Same letters, at most two words glued or split, never a negation:
/// « 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 {
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() {
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]) {
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);
} 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);
} else {
return false;
}
moves += 1;
if moves > MAX_TYPO_WORDS {
return false;
}
}
i == a.len() && j == b.len()
}
@@ -361,6 +372,32 @@ mod tests {
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]
fn accents_case_apostrophes_quotes() {
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
// 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 kind = if next == format!("{}\n{}", p.title, p.body) {
let kind = if title == p.title && body == p.body {
EditKind::Unchanged
} else {
let (voted_title, voted_body) = voted_text(&state, &p).await?;
match edit::classify(&format!("{voted_title}\n{voted_body}"), &next) {
EditKind::Unchanged => EditKind::Typographic, // back to the voted text
k => k,
}
let kinds = [
edit::classify(&voted_title, title),
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 {
EditKind::Unchanged => Ok(Json(json!({ "edit": "unchanged" }))),
@@ -363,7 +368,7 @@ pub async fn edit(
if p.status == "in_force" {
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 => {
if p.status == "in_force" {
@@ -418,6 +423,9 @@ pub async fn submit(
if p.status != "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();
sqlx::query("UPDATE proposals SET status = 'open', submitted_at = ?, updated_at = ? WHERE id = ?")
.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) {
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 = ?")
.bind(parts[0])
.bind(util::now())
+19
View File
@@ -251,6 +251,17 @@ async fn editing_is_not_voting() {
)
.await;
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.
let patch = |body: String| json!({ "title": e2["title"], "body": body });
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" })))
.await;
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) =
a.call("POST", &format!("/api/proposals/{p}/split"), Some(json!({ "parts": ["Première.", "Seconde."] }))).await;
assert_eq!(s, StatusCode::OK, "{v}");