feat: garde-fous d'écriture des classeurs Excel #153 (P0)

L'édition d'un .xlsx pouvait détruire une partie du classeur, le
concurrencer en silence, ou diffuser une injection de formule.

- BUG-085 : inspect_workbook() détecte ce qu'un round-trip openpyxl perd
  (valeurs calculées en cache, slicers, contrôles, connexions, custom
  XML, signature, commentaires enrichis, macros) → xlsx_lossy_features
  exposé en lecture, bandeau FR/EN, et 409 xlsx_lossy_content sans
  `force` (confirmation explicite puis reprise). Périmètre réel
  revalidé : graphiques, images et TCD survivent au round-trip.
- BUG-086 : écriture atomique (fichier .tmp + os.replace) : un plantage
  ne peut plus tronquer le classeur, le backup reste intact.
- BUG-087 : verrou par fichier autour du read-modify-write (timeout 15 s,
  409 conflict) ; endpoint xlsx/save devenu synchrone pour que
  l'attente s'exécute dans le threadpool.
- BUG-088 : une saisie en '=' ou '@' est stockée en texte, sauf opt-in
  `allow_formula` ou le bouton f(x) de la visionneuse. Le handler
  ServiceError expose désormais code + details, que api() propage.
- BUG-084 : la suppression d'une vault purge enfin l'index inversé
  (documents fantômes qui continuaient de matcher) et is_stale() devient
  is_ready(), le nom étant trompeur (la staleness n'existe plus).

Tests : 1390 pytest, 10 JSDOM (xlsx-viewer.test.mjs, branché au CI),
3 E2E Playwright, suite E2E complète verte, ruff/mypy 0.

🤖 Generated with Codebuff
Co-Authored-By: Codebuff <[email protected]>
This commit is contained in:
2026-09-27 20:39:12 -04:00
parent 4c4b1222d5
commit 31d4616baf
40 changed files with 1628 additions and 87 deletions
+113 -1
View File
@@ -6,7 +6,7 @@ Format basé sur [Keep a Changelog](https://keepachangelog.com/fr/1.1.0/),
et [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
> **En cours de développement** : les changements à venir sont listés dans la section
> [Unreleased](#unreleased). La dernière version livrée est **2.28.16**.
> [Unreleased](#unreleased). La dernière version livrée est **2.29.0**.
---
@@ -14,6 +14,118 @@ et [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
---
## [2.29.0] — 2026-09-27
### Correction
- **BUG-084 — l'index inversé conservait des documents fantômes après la
suppression d'une vault.** `remove_vault_from_index()`
(`backend/indexer.py`) ne notifiait pas le hook incrémental : après
suppression d'une vault, ses documents restaient dans l'index inversé
(`postings`, `doc_info`, `doc_vault`, `vault_docs`) et continuaient de
correspondre aux recherches pour une vault inexistante — seul un reindex
manuel les effaçait. Le correctif déclenche
`_on_index_change('remove', …)` pour chaque fichier de la vault, et
`_remove_doc_internals()` supprime désormais la clé `vault_docs` dont le set
devient vide (c'est un `defaultdict` : une lecture la recréait).
Test : `TestVaultRemovalPurgesInvertedIndex` (contre-preuve : échoue sans le
correctif).
### Maintenance
- **Index inversé — `is_stale()` renommé `is_ready()`.** La relecture de
`plan.md` a établi que les étapes 6 et 7 (suppression du cooldown et du hack
de coalescence) étaient **déjà livrées** : ni `_last_rebuild`, ni
`_rebuild_cooldown`, ni `_source_generation`, ni `_on_vault_change` ne
subsistent. `is_stale()` ne mesurait donc plus aucune staleness — il
indiquait seulement si l'index initial était construit, sous un nom
trompeur. Renommé `is_ready()`, cohérent avec le `is_ready()` de
`SemanticIndex` ; l'alias `is_stale()` de `SemanticIndex`, sans appelant, est
supprimé. `/api/diagnostics` expose désormais `is_ready` (libellé « Index
prêt » côté `frontend/js/config.js`). Tests :
`test_is_ready_tracks_initial_build`, `test_is_ready_survives_incremental_updates`.
- **`plan.md` recalibré.** Le fichier est désormais marqué « livré » et
suivi d'une section « État réel » : le code a divergé du plan sur quatre
points (pas de repli `_needs_rebuild`, `_ready` au lieu de `doc_count == 0`,
`rebuild()` conservé au démarrage, `is_stale()` repurposé). Les extraits de
code du plan sont explicitement signalés comme ne décrivant pas le code
actuel.
- **Fixtures de test locales exclues du suivi Git.** `test_dir/music/`,
`test_dir/video/`, `test_vault/images/`, `test_vault/markdown/` et
`test_vault/budget.xlsx` (~200 Mo) sont ajoutés au `.gitignore` : aucun test
ni job CI ne les référence — les tests unitaires génèrent leurs fixtures dans
`tmp_path` et l'E2E n'utilise que les fixtures committées
(`test_vault/sample-*.{mp3,png,svg,webm,pdf}`, `test_dir/*.md`). Ils
restaient non suivis et polluaient `git status`.
### Sécurité
- **BUG-088 — plus d'injection de formule via la visionneuse Excel.** Une
saisie `=cmd|'/c calc'!A1` (ou `@…`) était stockée comme **formule** par
openpyxl, donc exécutée par Excel à la réouverture du fichier (DDE).
`edit_xlsx_cells` force maintenant le type texte (`cell.data_type = "s"`)
pour toute valeur commençant par `=` ou `@` ; l'API accepte
`allow_formula: true` et la visionneuse expose un bouton `f(x)`
(opt-in, état de session, jamais persisté). `+`/`-` restent des nombres.
- **BUG-087 — écriture concurrente d'un classeur.** `load_workbook()` →
`save()` n'était pas sérialisé : deux sauvegardes simultanées (deux
onglets, l'agent IA et la visionneuse) faisaient gagner la dernière, en
silence. Verrou par chemin (`backend/services/mutations.py::_xlsx_write_lock`,
timeout 15 s) autour du cycle lecture → édition → remplacement ; attente
dépassée → **409** `conflict`. L'endpoint `PUT …/xlsx/save` est devenu
synchrone pour que l'attente s'exécute dans le threadpool.
### Corrigé
- **BUG-085 — la perte de données à l'enregistrement d'un `.xlsx` est
annoncée, plus silencieuse.** `GET /api/file/{vault}` renvoie
`xlsx_lossy_features` (éléments qu'un round-trip openpyxl perd) ; la
visionneuse affiche un bandeau listant ces éléments et la première
sauvegarde demande confirmation avant de renvoyer `force: true`. Sans
`force`, l'API répond **409** `xlsx_lossy_content` avec
`details.features`. Périmètre **remesuré** sur openpyxl 3.1.5 : graphiques,
images, dessins et tableaux croisés sont bien préservés ; sont perdus les
valeurs calculées en cache, slicers/chronologies, contrôles de formulaire,
connexions/requêtes, custom XML, signature numérique, commentaires
enrichis et macros.
- **BUG-086 — écriture atomique des classeurs.** `wb.save()` écrivait en
place sur le fichier du vault : un plantage laissait un `.xlsx` tronqué.
L'écriture passe désormais par un `.tmp` puis `os.replace()` (le backup
`.bak` est inchangé, le `.tmp` est ignoré par le watcher).
- Le handler global `ServiceError` expose maintenant `code` et `details` dans
la réponse JSON, et `api()` (frontend) les propage sur l'Error — nécessaire
pour que le client distingue un 409 de confirmation d'une autre erreur.
### Ajouté
- **#153 (P0) — tests de la visionneuse Excel.**
`tests/frontend/xlsx-viewer.test.mjs` (10 tests JSDOM : bannière,
confirmation + reprise `force`, refus, toggle `f(x)`, payload de
sauvegarde) et `tests/e2e/xlsx-viewer.spec.js` (3 tests Playwright sur la
fixture `test_vault/sample-xlsx-lossy.xlsx`) ; la suite JSDOM est branchée
dans le CI.
### Documentation
- **#153 — Visionneuse & édition XLSX : audit complet et backlog de
complétude.** La visionneuse `.xlsx` livrée par #152 a été auditée couche
par couche (lecture `backend/xlsx_reader.py`, écriture
`backend/services.mutations.edit_xlsx_cells`, UI `renderXlsxViewer`,
indexation, outils IA, tests). Bilan : la grille de valeurs est éditée
correctement (sécurité, backup, audit, échappement HTML), mais l'ensemble
supporté est étroit, une partie du classeur est perdue à l'enregistrement,
les tableurs sont **invisibles pour la recherche** et l'IA ne sait que les
**créer**. Ouverture de l'item **#153** dans `docs/ROADMAP.md` (17
sous-tâches suivies **A1 → A17** ; **P0 livré**, reste P1 recherche/IA/UX
puis P2 étendu) et création de la fiche
[docs/features/xlsx-viewer.md](docs/features/xlsx-viewer.md) : cartographie
du code, limites par couche, tableau des risques R1-R5 et critères
d'acceptation par sous-tâche.
---
## [2.28.16] — 2026-09-27
---