Compare commits

..

4 commits

Author SHA1 Message Date
le king fu
c5efc38e96 fix(deps): build the updater without its zip feature
All checks were successful
PR Check — Rust / rust (pull_request) Successful in 4m14s
Clears RUSTSEC-2026-0367, RUSTSEC-2026-0368 and RUSTSEC-2026-0369 on
zip 4.6.1, which turned the daily audit.yml red at run 558 (2026-10-17)
without any lockfile change. zip was compiled only for Windows, through
the default `zip` feature of tauri-plugin-updater 2.10.0. It is fixed
only in zip >= 9.0.0, and tauri-plugin-updater 2.14.0 still requires
zip ^4, so no cargo update can reach a fix.

That feature only unpacks archived update packages (.nsis.zip,
.msi.zip, .AppImage.tar.gz, produced by createUpdaterArtifacts
"v1Compatible"); this app ships createUpdaterArtifacts: true, whose
packages are never archives. The updater is now declared without its
default features, keeping rustls-tls. The lockfile loses zip, arbitrary
and derive_arbitrary (666 -> 663 packages, nothing added), and tar
leaves the Linux graph.

tar was the canary of the suppression guard in check-rust.yml. It is
replaced by webkit2gtk, Tauri's Linux webview, which keeps the property
that made tar useful: a dependency present on x86_64-unknown-linux-gnu
only, so finding it also proves that --target resolves conditional
edges. ADR 0018, architecture.md, qa-update-cycle.md and the release
skill are updated to match.

Verified locally: cargo-audit 0.22.2 exits 1 on the old lock with the
three IDs and 0 on the new one (663 crates, the same 12 allowed
warnings); the suppression guard is green, and red with the canary
swapped for tar or zip; windows-sys guard 9/9; cargo check and cargo
test (267 passed) on Linux; cargo xwin check for x86_64-pc-windows-msvc
is green on main (zip compiled) and on this branch (zip gone).

Refs #455

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-18 12:37:48 -04:00
le king fu
54c48f4721 state: sync after #456
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-18 09:23:21 -04:00
le king fu
24da72b7f4 docs(review): apply the review of #460, version the calibration script
All checks were successful
PR Check — Frontend / frontend (pull_request) Successful in 1m55s
REVIEW.md, from the suggestions of the first review of #460:
- stacked-PR pitfall: fs_commands.rs compiles two CSV files of
  src/__fixtures__/samples/ through include_str!, outside the paths of
  check-rust;
- high level: .claude/settings.json and .mcp.json, a pull_request_target
  trigger, an install step that changes the toolchain or the PATH (the
  check-workflow list is now given as examples of the principle), a lock
  origin whose host or scheme changes even with a new version, and a diff
  that removes or bypasses a consent (price consent lives on the front);
- db.ts and dataExportService.ts stay normal (Max's decision of
  2026-10-18), and the data zone now names the lock, the profile switch
  and the restore;
- release zone: cargo-xwin and the Rust toolchain, installed unpinned at
  each tag, also run with the signing key;
- windows-sys guard: EXPECTED joins CRATES and PIN;
- identifier: changing it orphans the databases and the app_data_dir
  files (license key, activation token, token fallback); the keychain
  service is a constant and does not follow.

scripts/review-levels.py is the calibration script of the PR body, now in
the repo: pages of 50 files read until an empty page (the instance caps
pages at 50), the new rules above, comment lines ignored by the workflow
checks, and --check, which compares its patterns with the Haut table
without network access. Fresh run: 3/28/8 on the 39 reference PRs and
6/33/12 on the 51, unchanged.

Refs #456

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-18 08:55:37 -04:00
le king fu
fa1abb115c docs(review): add REVIEW.md with calibrated risk levels and review rules per zone
REVIEW.md sets the review level of a PR from the files its diff touches
(high, normal or low; the highest wins, never the author), lists what
matters in each zone with its source (a CLAUDE.md section or an ADR,
completed by the repo file where the fact lives), the verification
commands of the CI, and the review pitfalls of this repo. The pitfalls
taken from the global pr-review skill are corrected for this repo:
stacked PRs do get CI here since #232.

Calibration on the merged PRs #344 to #452: the 39 reference PRs of the
pilot document come out 3 low / 28 normal / 8 high (20.5 %, target
20 %), the whole range of 51 PRs 6 / 33 / 12 (23.5 %), against 3 / 19 /
17 with the trial patterns. A lockfile-only version bump of a package
already present stays normal, per Max's decision of 2026-10-17.
CLAUDE.md points to the file.

Fixes #456

Note: plan check bypassed after 3 passes, last findings applied (Max's
decision of 2026-10-18).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-18 08:21:05 -04:00
4 changed files with 591 additions and 4 deletions

View file

@ -134,6 +134,8 @@ La documentation technique est centralisée dans `docs/` :
- `docs/guide-utilisateur.md` — Guide utilisateur
- `docs/archive/` — Anciennes spécifications archivées
Les règles de revue des PR (niveaux de risque, ce qui compte par zone, commandes de vérification, pièges) sont dans [`REVIEW.md`](REVIEW.md), à la racine ; un changement qui rend faux un fait qu'il cite le met à jour dans la même PR.
**Règle : quand un changement touche l'architecture, mettre à jour la documentation :**
- Nouveau service, hook, commande Tauri, page/route, ou table SQL → mettre à jour `docs/architecture.md`
- Décision technique structurante (choix de librairie, pattern architectural, changement de stratégie) → créer un nouvel ADR dans `docs/adr/`

190
REVIEW.md Normal file
View file

@ -0,0 +1,190 @@
# REVIEW.md — Simpl'Résultat
Règles de revue des PR de ce dépôt : niveau de risque, ce qui compte dans chaque zone, commandes de vérification et pièges connus. Ce fichier complète `CLAUDE.md` et `.claude/rules/` sans les remplacer : une zone en reprend ce qui sert à la revue, avec sa source.
À partir de #457, `/pr-review` lit ce fichier sur `main`, y compris pour une PR empilée : une PR qui le modifie est jugée par la version de `main`, et ses nouvelles règles valent pour les PR suivantes.
## Niveaux
Le niveau fixe la profondeur de la revue, que `/pr-review` règle pour chaque niveau à partir de #457. Il se calcule sur les fichiers que touche le diff, jamais sur l'auteur (Max, `/fix-issue`, `/autopilot`) :
- un fichier désigné par une ligne du tableau **Haut** dont la condition est remplie est de niveau haut ; sinon, un fichier désigné par **Bas** est de niveau bas ; sinon il est normal ;
- la PR prend le niveau le plus haut de ses fichiers, et la revue l'annonce avec le fichier qui le fixe ;
- un fichier renommé, déplacé ou supprimé se juge sur son ancien et sur son nouveau chemin : un fichier de niveau haut déplacé reste haut, et la PR qui le déplace met ce tableau à jour ;
- une condition qu'on ne sait pas trancher à la lecture du diff compte comme remplie.
Motifs : chemins depuis la racine. `*` ne traverse jamais un `/` ; `**/` vaut zéro, un ou plusieurs dossiers (`**/*.md` couvre `CLAUDE.md`, à la racine) ; `{a,b}` liste des variantes.
### Haut
| Motif | Condition | Zone |
|---|---|---|
| `src-tauri/src/database/**` | toujours | Migrations, profils et données |
| `src-tauri/src/lib.rs` | sauf si le diff se limite à des entrées de `generate_handler![…]` ou au module `tests` | Migrations (`migrations()`), plugins, deep link OAuth |
| `src-tauri/src/commands/profile_commands.rs` | toujours | Migrations, profils et données |
| `src-tauri/src/commands/{license_commands,entitlements,auth_commands,token_store,account_cache}.rs`, `src/services/{licenseService,authService}.ts`, `.claude/skills/issue-license/**` | toujours | Jetons, licence et édition |
| `src-tauri/src/commands/balance_commands.rs` | le diff touche `fetch_price` ou la lecture du jeton d'activation | Jetons, licence et édition |
| `src-tauri/src/commands/export_import_commands.rs` | toujours | Jetons, licence et édition (export chiffré) |
| `src-tauri/src/commands/feedback_commands.rs`, `src/services/feedbackService.ts` | toujours | Vie privée |
| code livré : `src/**`, `src-tauri/src/**`, `index.html`, `public/**` | le diff ajoute un appel réseau, change ce qu'un appel existant envoie, ouvre une URL qui porte une donnée de l'utilisateur (donnée financière, identifiant, contenu d'un profil, ligne du Journal), ou retire ou contourne un consentement (celui des cours vit côté front : `src/components/balance/PriceFetchControl.tsx`, `src/components/settings/PriceFetchConsentToggle.tsx`) | Vie privée |
| `src-tauri/capabilities/**` | toujours | Vie privée |
| `src-tauri/{tauri*.conf.json*,Tauri*.toml}` | sauf si le diff se limite au champ `"version"` ; un fichier propre à une plateforme est fusionné avec `tauri.conf.json` | Vie privée (CSP), Release (updater, `beforeBuildCommand`) |
| `.forgejo/workflows/release.yml`, `scripts/build-latest-json*`, `scripts/check-latest-json.jq`, `.claude/skills/release/**` | toujours | Release et mise à jour |
| `src-tauri/build.rs`, `{vite,vitest,postcss}.config.*`, `.postcssrc*`, `.npmrc`, `**/rust-toolchain*`, `Cargo.{toml,lock}` à la racine | toujours | Release et mise à jour, CI et gardes (lu ou exécuté par le build de la release ou par `npm test`) |
| fichier à la racine du dépôt ou directement dans `src-tauri/`, hors `*.md` | ajouté, renommé ou supprimé, comme tout fichier d'un dossier caché nouveau : une configuration nouvelle peut changer le build ou la CI sans motif qui la nomme | Release et mise à jour, CI et gardes |
| `src-tauri/Cargo.toml`, `package.json` | sauf si le diff se limite au champ `version` de l'app (dépendances, features dont `dev-override`, `overrides`, scripts) | Dépendances ; Release pour les `scripts` de `package.json` |
| `src-tauri/Cargo.lock`, `package-lock.json` | le diff ajoute ou retire un paquet (un bloc `[[package]]` dont la ligne `name =` apparaît ou disparaît, une seconde version d'un crate comprise ; une clé `"node_modules/<nom>"` qui apparaît ou disparaît), ou change l'origine d'un paquet : hôte ou schéma de `source` (Cargo) ou de `resolved` (npm), avec ou sans nouvelle version, ou `checksum`, `integrity` changés sans nouvelle version | Dépendances |
| `**/.cargo/**` | toujours | Dépendances |
| `.forgejo/workflows/**` | fichier ajouté, ou diff qui ajoute une référence à `secrets` ou un déclencheur `pull_request_target`, qui exécute la définition de la base avec ses secrets pour toute PR, forks compris | CI et gardes |
| `.forgejo/workflows/audit.yml` | le diff change une ligne active : sans déclencheur `pull_request`, il tourne pour la première fois sur `main` | CI et gardes |
| `.forgejo/workflows/{check-rust,check-frontend}.yml` | le diff peut faire passer au vert ce qui échouait, par exemple : déclencheur retiré ou restreint (`paths`, `paths-ignore`, `branches`, `schedule`) ; étape de verdict modifiée ou retirée (`npm ci`, `npm run build`, `npm test`, `cargo check`, `cargo test`, `Verify …`, `cargo audit`) ; `continue-on-error` ou `\|\| true` ajouté ; étape `run:` ajoutée qui écrit `$GITHUB_ENV`, `$GITHUB_PATH` ou un fichier du dépôt ; action `uses:` ajoutée, hors cache ; étape d'installation modifiée (toolchain, binaire placé dans le `PATH`) ; `if:`, `defaults:`, `permissions:`, `env:` ou `container:` modifiés | CI et gardes |
| `.claude/settings.json`, `.mcp.json` | toujours : hooks, permissions et serveurs MCP décident de ce que les agents exécutent sur le poste, sans CI pour le voir | CI et gardes |
| `REVIEW.md` | le diff touche la section « Niveaux » | Règles de revue |
Restent au moins normaux, et leurs règles de zone s'appliquent quand même :
- une montée de version d'un paquet déjà présent dans un lock, correctif d'advisory compris (décision de Max du 2026-10-17, consignée sur #456), et une arête re-résolue vers une version déjà présente : seul le garde `windows-sys` de la CI en surveille, pour ses sept crates ([ADR 0023](docs/adr/0023-windows-sys-pinning.md)) ;
- dans `check-rust.yml` et `check-frontend.yml` : un déclencheur ajouté, sauf `pull_request_target`, une étape `run:` ajoutée qui ne fait que lire, un cache (`Swatinem/rust-cache`, `actions/cache`), un commentaire, la version de Node ;
- `src/services/db.ts` et `src/services/dataExportService.ts`, même quand le diff touche le verrou, le changement de profil ou les suppressions de la restauration (décision de Max du 2026-10-18, revue de #460) : contre leurs défauts, la mutation du niveau normal est le bon outil ;
- `src-tauri/src/commands/{fs_commands,backup_commands}.rs`, et le reste de `balance_commands.rs` ;
- `src/shared/entitlements.ts` : la matrice ne verrouille que l'interface ([ADR 0017](docs/adr/0017-feature-gating-par-tier.md), § 2) ;
- `CLAUDE.md`, `REVIEW.md` hors de la section « Niveaux », `docs/adr/**`, `docs/architecture.md`, `docs/qa-update-cycle.md` et `.claude/**` : sources de ce fichier et instructions des agents, jamais de niveau bas.
### Bas
Une PR est de niveau bas quand tous ses fichiers sont désignés par `**/*.md`, `docs/**`, `LICENSE`, `tasks/**` ou `reports/**`, hors des exceptions de la liste précédente. `STATE.md` reste bas malgré son import par `CLAUDE.md` : il résume l'état du projet, il ne fixe pas de règle.
### Normal
Tout le reste : code applicatif et tests, traductions (`src/i18n/locales/*.json`), `public/`, configuration TypeScript, `.github/**` (miroir dormant).
### Calibration
Rejouée le 2026-10-17 (#456) sur les PR mergées de #344 à #452 : les 39 PR de référence du document de départ du pilote, dont la liste est figée dans la PR qui ferme #456, et la plage complète, soit 51 PR.
| Ensemble | Bas | Normal | Haut |
|---|---|---|---|
| 39 PR de référence, motifs d'essai | 3 | 19 | 17 (44 %) |
| 39 PR de référence, ces motifs | 3 | 28 | 8 (21 %, pour une cible de 20 %) |
| 51 PR de la plage, ces motifs | 6 | 33 | 12 (24 %) |
Niveau haut sur les 51 : licence (#359, #364, #366, #368, #386, #387, #388), migrations et profils (#394, #436), release (#414, #416, #418) ; parmi les 39 de référence, #386, #387, #388, #394, #414, #416, #418 et #436. L'écart à la cible est justifié dans la PR qui ferme #456, qui consigne aussi le niveau de chaque PR. Le script qui les calcule est `scripts/review-levels.py` (API Forgejo, lecture seule) ; `python3 scripts/review-levels.py --check` vérifie, sans réseau, que ses motifs sont ceux du tableau Haut.
Une PR qui modifie cette section dit, pour chaque motif retiré ou assoupli, ce qu'il protégeait, met `scripts/review-levels.py` à jour dans le même diff et le rejoue sur les mêmes PR ; le pilote #458 mesure si les niveaux tiennent.
## Ce qui compte par zone
Chaque règle cite sa source. Elles valent quel que soit le niveau : une PR normale qui touche une zone les respecte aussi.
### Vie privée
- Aucune donnée financière ne quitte le poste : ni télémétrie, ni analytics, et le CSV comme la base restent locaux. Un appel réseau ajouté se justifie dans la PR : il n'envoie rien de financier ni aucun identifiant brut, et il passe derrière un consentement ou l'édition. — `CLAUDE.md` « Privacy-first — NON NÉGOCIABLE », [ADR 0009](docs/adr/0009-proxy-price-fetching-via-maximus-api.md), [ADR 0020](docs/adr/0020-identifiant-machine-derive.md)
- Les appels sortants partent tous du Rust (`reqwest`) : `auth_commands.rs` (compte), `license_commands.rs` (licence et activation), `balance_commands.rs` (`fetch_price`, qui porte le jeton d'activation) et `feedback_commands.rs`, plus l'endpoint du plugin d'updater. Le `connect-src` de `app.security.csp` (`api.`, `auth.` et `feedback.lacompagniemaximus.com`) ne borne que la webview : lire chaque URL, chaque en-tête et chaque corps envoyé. — `CLAUDE.md` « Privacy-first — NON NÉGOCIABLE », `src-tauri/tauri.conf.json`, `src-tauri/src/commands/`
- Les cours passent par `maximus-api`, après un consentement explicite par profil, avec les seuls en-têtes `Authorization`, `Accept` et `User-Agent` : le fournisseur tiers ne voit jamais le poste. Ce consentement n'est vérifié que côté front (`PriceFetchControl.tsx`, `PriceFetchConsentToggle.tsx`) : `fetch_price` ne le revérifie pas. — [ADR 0009](docs/adr/0009-proxy-price-fetching-via-maximus-api.md), `src-tauri/src/commands/balance_commands.rs`
- Seule l'activation envoie un identifiant de la machine, et seulement le dérivé (`HMAC-SHA256` de l'identifiant du système) ; un jeton émis avant la dérivation porte encore l'identifiant brut jusqu'à la réactivation. — [ADR 0020](docs/adr/0020-identifiant-machine-derive.md), § 2 et 5
- Le Feedback Hub reçoit le texte saisi, suivi au choix des dernières erreurs du Journal, un `userId` facultatif et un contexte borné par `FeedbackContext` (page, langue, thème, fenêtre, user-agent, horodatage) : un champ ajouté au contexte élargit ce qui part. — `CLAUDE.md` « Privacy-first — NON NÉGOCIABLE », `src/services/feedbackService.ts`, `src/components/settings/FeedbackDialog.tsx`
- La webview n'a que les permissions de `src-tauri/capabilities/default.json` : l'accès aux fichiers passe par les commandes Rust, jamais par une permission `fs`, et une permission ajoutée se justifie. — `CLAUDE.md` « Règles d'architecture »
- Un message du Journal nomme un fichier, jamais son dossier : le chemin contient le nom d'utilisateur, et une ligne d'erreur du Journal peut partir avec une rétroaction (règle posée pour le rattrapage des profils). — [ADR 0021](docs/adr/0021-migrations-profils-secondaires.md)
### Précision financière et import
- Montants : virgule ou point décimal, espaces, symboles monétaires, négatifs, cellules vides et formats inattendus ont chacun leur test. — `CLAUDE.md` « Précision financière » et « Conventions de code » (Général)
- Encodages CSV : UTF-8, Windows-1252, ISO-8859-15. — `CLAUDE.md` « Précision financière »
- Le format d'import est persisté en entier sur la source et lu tel quel, jamais re-deviné ; l'aperçu reste obligatoire avant tout import. — [ADR 0019](docs/adr/0019-format-import-persiste.md), `CLAUDE.md` « Fonctionnalités principales »
### Migrations, profils et données
- Jamais de modification d'une migration existante : son checksum est vérifié au démarrage et épinglé par le test `migration_checksums_match_the_released_ones`. On ajoute une version. — `CLAUDE.md` « Base de données », [ADR 0003](docs/adr/0003-sqlx-migrations.md), `.claude/rules/sql-migrations.md`
- Une migration atteint deux chemins et reste correcte sur les deux : le profil par défaut, migré par tauri-plugin-sql (sqlx), et chaque profil secondaire, rattrapé à son ouverture par `prepare_profile_db` (rusqlite, clés étrangères actives). — [ADR 0021](docs/adr/0021-migrations-profils-secondaires.md), `.claude/rules/sql-migrations.md`
- Tout changement de schéma et toute correction de données (seed, mots-clés, catégories) se reportent dans `consolidated_schema.sql` : un profil neuf naît avec l'historique complet et ne rejoue aucune migration. Aucun marqueur ajouté à `infer_floor`, réservé aux bases créées avant #390. — [ADR 0021](docs/adr/0021-migrations-profils-secondaires.md), `.claude/rules/sql-migrations.md`
- Les comptes de tables, d'index et de migrations de `CLAUDE.md` et de `docs/architecture.md` suivent. — `CLAUDE.md` « Base de données » et « Documentation technique »
- Les bases vivent sous `app_config_dir()`, qui n'est pas `app_data_dir()` sous Linux. Les deux dossiers dérivent de l'`identifier` de `tauri.conf.json` : le changer orpheline les bases des profils et, sous `app_data_dir()`, la clé de licence, le jeton d'activation et le fichier de repli des jetons OAuth. Le service du trousseau (`KEYCHAIN_SERVICE` de `token_store.rs`) est une constante alignée sur l'`identifier` par convention : il ne suit pas. — [ADR 0021](docs/adr/0021-migrations-profils-secondaires.md), [ADR 0006](docs/adr/0006-oauth-tokens-keychain.md), `CLAUDE.md` « Base de données », `src-tauri/src/commands/license_commands.rs`
- Toute requête passe par le verrou unique de `db.ts`, et une transaction par `withTransaction` et son handle ; un changement de profil ferme puis rouvre la connexion. La restauration d'une sauvegarde (`runRestore` de `dataExportService.ts`) efface puis réinsère dans une seule transaction et ne notifie qu'après le `COMMIT`, une fois le verrou relâché. — [ADR 0022](docs/adr/0022-canal-evenements-donnees.md), [ADR 0005](docs/adr/0005-multi-profile-db.md), `docs/architecture.md` « Accès DB et transactions »
- Un booléen se lie en `1`/`0` (`toSqlFlag`), jamais en `true`/`false`. — [ADR 0021](docs/adr/0021-migrations-profils-secondaires.md)
- Une base par profil, PIN haché par Argon2. — [ADR 0005](docs/adr/0005-multi-profile-db.md)
- Une nouvelle écriture d'une table que lit `COUNTERS_SQL` appelle `notifyDataChanged()` après succès, avec un test qui prouve la notification. — [ADR 0022](docs/adr/0022-canal-evenements-donnees.md)
### Jetons, licence et édition
- Jetons OAuth dans le trousseau du système ; repli sur un fichier `0600` seulement sans trousseau, et plus de retour au fichier une fois le trousseau utilisé. — [ADR 0006](docs/adr/0006-oauth-tokens-keychain.md)
- Le callback `simpl-resultat://auth/callback` n'est traité que par l'instance qui détient le vérificateur PKCE : `tauri-plugin-single-instance` reste le premier plugin enregistré, et un callback sans `code` remonte une erreur au front. — [ADR 0006](docs/adr/0006-oauth-tokens-keychain.md), `docs/architecture.md` « OAuth2 et deep-link (Compte Maximus) »
- Export chiffré SREF : AES-256-GCM, clé dérivée du mot de passe par Argon2id, sel et nonce aléatoires. — [ADR 0004](docs/adr/0004-aes-256-gcm-encryption.md)
- La dérivation de l'identifiant machine est figée (`derive_machine_id`), et un jeton émis avant elle reste accepté. Une montée de `machine-uid` se vérifie : une forme renvoyée différente changerait l'identifiant dérivé de chaque machine. — [ADR 0020](docs/adr/0020-identifiant-machine-derive.md), § 1, 3 et « À surveiller »
- `isEntitled` est fail-closed en Free : le court-circuit `free` passe avant l'override signé `features[]` (CWE-863). `useEntitlement` rend `{ allowed, ready }`. — [ADR 0017](docs/adr/0017-feature-gating-par-tier.md), § 1 et 3
- Une fonctionnalité verrouillée reste visible, jamais masquée ; le gating bloque l'accès, jamais les données. — [ADR 0017](docs/adr/0017-feature-gating-par-tier.md), § 4 et 5
- Le gating ne s'applique qu'à l'interface (soft-paywall assumé, l'app est GPL), et `SR_DEV_EDITION` n'existe que dans un build compilé avec la feature `dev-override`. — [ADR 0017](docs/adr/0017-feature-gating-par-tier.md), § 2 et 6
### Dépendances
- `.cargo/audit.toml` n'admet une advisory que si son crate est absent de toutes les cibles livrées (`cargo tree -i <crate> --target <triple>`, Linux et Windows, jamais `--target all`) ou si aucun correctif n'existe. Une entrée porte un ID, jamais un crate ; elle ajoute son crate à la liste du garde de `check-rust.yml` et a son issue de suivi, qui porte la condition de retrait. — [ADR 0018](docs/adr/0018-suppression-advisories-non-atteignables.md), en-tête de `.cargo/audit.toml`, `CLAUDE.md` « CI/CD »
- Garde `windows-sys` : s'il rougit après un `cargo update`, remettre les arêtes à la main selon l'ADR ; ne jamais modifier la liste gardée (`CRATES`), la version épinglée (`PIN`), l'ensemble attendu (`EXPECTED`) ni le canari pour le faire passer. Un `cargo update` ciblé peut déplacer d'autres arêtes sans le dire : chaque entrée qui bouge dans le lock doit s'expliquer. — [ADR 0023](docs/adr/0023-windows-sys-pinning.md), `CLAUDE.md` « CI/CD »
- Une version montée exécute son nouveau code au build de la release, clé de signature dans l'environnement : scripts de build et macros des crates, code des paquets npm que lancent `npm run build` et `npx tauri build`. Lire ce que change la version, pas seulement son numéro. — `CLAUDE.md` « CI/CD », `docs/architecture.md` « `release.yml` — Build et publication »
- Une advisory corrigée se prouve par `cargo audit` lancé depuis la racine, là où `.cargo/audit.toml` s'applique. `npm audit` n'est pas un gate de CI. — `CLAUDE.md` « CI/CD »
### Release et mise à jour
- `release.yml` met la clé de signature de l'updater (`TAURI_SIGNING_PRIVATE_KEY`) dans l'environnement des deux étapes `npx tauri build`. S'exécutent avec elle : `beforeBuildCommand` (`npm run build`, donc `tsc`, `vite.config.ts` et les `scripts` de `package.json`), `@tauri-apps/cli` et son binaire natif, qui lit la clé, `cargo-xwin` (`--runner cargo-xwin` pour Windows) et la toolchain Rust, installés à chaque tag sans version épinglée, `src-tauri/build.rs`, les scripts de build et les macros des crates, et tout module JS qu'une directive CSS `@plugin` ou `@config` fait charger. `npm ci` tourne avant, sans elle, mais ce qu'il installe s'exécute ensuite avec elle. — `CLAUDE.md` « CI/CD », `docs/architecture.md` « `release.yml` — Build et publication »
- `latest.json` porte une clé par paquet (`linux-x86_64-deb`, `linux-x86_64-rpm`, `windows-x86_64`), contrôlée par `scripts/check-latest-json.jq` avant le `DELETE` du registre ; `scripts/build-latest-json.test.mjs` tourne dans `npm test`. — `CLAUDE.md` « CI/CD », `docs/architecture.md` « `release.yml` — Build et publication »
- `release.yml` signe les binaires et publie le JSON d'updater que lisent toutes les installations : une clé publique de `plugins.updater` changée sans bascule prévue coupe leurs mises à jour, comme un endpoint déplacé. — `CLAUDE.md` « CI/CD » et « Fonctionnalités principales »
- Une release qui touche l'updater, ses dépendances, `release.yml` ou la config `updater` exige un cycle réel de mise à jour (étape 10 de `/release`, `docs/qa-update-cycle.md`) : la PR le signale pour la release suivante. `verify-release.sh` contrôle la release publiée. — `CLAUDE.md` « CI/CD », `.claude/skills/release/SKILL.md`
### CI et gardes
- Aucun workflow `check-*` ne filtre sur `branches:` : `branches: [main]` avait laissé sans CI quatre des cinq PR empilées de la milestone feature-gating. `check-frontend.yml` exclut par `paths-ignore` pour qu'un fichier nouveau déclenche la CI au lieu de la sauter. — `CLAUDE.md` « CI/CD », en-têtes de `check-rust.yml` et `check-frontend.yml`
- `audit.yml` est le seul canal des avis RustSec : quotidien et bloquant. L'étape `cargo audit` de `check-rust` est non bloquante par choix. — `CLAUDE.md` « CI/CD »
- Une garde affiche chacune de ses vérifications, y compris celles qui passent, et lit un canari : une garde muette ne prouve rien. — [ADR 0018](docs/adr/0018-suppression-advisories-non-atteignables.md) (règle 3), [ADR 0023](docs/adr/0023-windows-sys-pinning.md)
- Une PR qui ne touche que `audit.yml` ou `release.yml` n'a aucune CI : les `paths` de `check-rust.yml` ne les listent pas, et le `paths-ignore` de `check-frontend.yml` les exclut. La revue rejoue elle-même ce qui peut l'être. — `CLAUDE.md` « CI/CD », `check-rust.yml`, `check-frontend.yml`
- Les secrets du dépôt (clé de signature et son mot de passe, jeton du registre de paquets qui remplace `latest.json`) sont lisibles par tout workflow : un workflow ajouté, une référence à `secrets` ajoutée ou un déclencheur `pull_request_target` se lit comme du code de release. — `CLAUDE.md` « CI/CD », `release.yml`
### i18n et documentation
- Toute chaîne visible passe par i18next, dans `fr.json` et `en.json` à la fois ; aucun texte en dur dans un composant. — `CLAUDE.md` « Internationalisation (i18n) », `.claude/rules/i18n.md`
- Un changement visible ajoute une entrée sous `[Unreleased]` dans `CHANGELOG.md` et `CHANGELOG.fr.md`. — `CLAUDE.md` « Documentation technique » (Règle CHANGELOG)
- Un changement d'usage met à jour `docs/guide-utilisateur.md` et les clés `docs.*` ; un service, un hook, une commande, une page ou une table de plus met à jour `docs/architecture.md` ; une décision structurante a son ADR. — `CLAUDE.md` « Documentation technique »
### Règles de revue
- Un changement qui rend faux un fait cité ici (sites d'appel réseau, hôtes de `connect-src`, commandes et déclencheurs de la CI, chemins du tableau Haut) met ce fichier à jour dans la même PR. — `CLAUDE.md` « Documentation technique »
## Commandes de vérification
Les workflows font foi ; ce tableau les résume au 2026-10-17. Le runner a une capacité de 1 : les jobs se suivent, et chaque maillon d'une pile a sa CI. — `CLAUDE.md` « CI/CD »
| Workflow | Déclencheur | Commandes |
|---|---|---|
| `check-frontend.yml` | PR, sauf si tous ses fichiers sont exclus : `src-tauri/**`, `.cargo/**`, `docs/**`, `reports/**`, `tasks/**`, `.github/**`, `.claude/**`, `*.md` à la racine, `LICENSE`, les trois autres workflows | `npm ci`, `npm run build` (tsc + vite), `npm test` (vitest, `scripts/build-latest-json.test.mjs` compris) |
| `check-rust.yml` | PR qui touche `src-tauri/**`, `.cargo/**` ou ce workflow ; lundi 06:30 UTC sur `main` ; `workflow_dispatch` | `cargo check --manifest-path src-tauri/Cargo.toml --all-targets`, garde des advisories supprimées, garde `windows-sys`, `cargo test --manifest-path src-tauri/Cargo.toml --all-targets`, `cargo audit --file src-tauri/Cargo.lock` (non bloquant) |
| `audit.yml` | chaque jour à 06:00 UTC ; `workflow_dispatch` | `cargo-audit audit --file src-tauri/Cargo.lock` (bloquant) |
| `release.yml` | tag `v*` | build Linux (`.deb`, `.rpm`) et Windows (cargo-xwin), signature, `latest.json` |
`cargo check` et `cargo test` tournent sans `--locked` dans la CI ; #449 l'ajoute, et sa PR met ce tableau à jour.
En local, les mêmes commandes, depuis la racine :
```bash
npm ci && npm run build && npm test
cargo check --manifest-path src-tauri/Cargo.toml --all-targets
cargo test --manifest-path src-tauri/Cargo.toml --all-targets
cargo audit --file src-tauri/Cargo.lock # depuis la racine : .cargo/audit.toml s'applique
```
- `npm test` lancé à la racine parcourt aussi `.claude/worktrees/` (`vite.config.ts` ne l'exclut pas) : vider ce dossier avant, ou lancer la suite depuis le worktree. — `.claude/skills/release/SKILL.md` (étape 0)
- Les gardes de `check-rust.yml` se rejouent en exécutant le bloc `run:` de leur étape depuis la racine, sous `bash --noprofile --norc -e -o pipefail`, le shell du runner dans un conteneur `ubuntu:22.04`.
- Windows, pour une dépendance, une feature ou du code sous `cfg(windows)`, sur un poste où cargo-xwin est déjà installé : `cargo xwin check --manifest-path src-tauri/Cargo.toml --locked --target x86_64-pc-windows-msvc`, précédé d'un témoin sur `main` (un témoin rouge met l'outil en cause, pas la PR). Installer cargo-xwin vaut acceptation de la licence du CRT et du SDK Windows de Microsoft : une revue ne l'installe jamais. (#455)
- Un `npm test` rejoué hors CI ne compte qu'après une sonde : un test volontairement rouge doit le faire sortir en code non nul.
## Pièges de revue
Les trois premiers viennent du skill global `pr-review`, corrigés pour ce dépôt.
- **CI d'une pile.** Chaque maillon a sa CI depuis #232 : la section « CI coverage gap » du skill (CI réservée aux PR qui ciblent `main`) ne vaut pas ici. `check-rust` ne tourne que si le diff du maillon touche ses chemins : l'état Rust d'une pile est prouvé par le dernier maillon qui en a touché. Exception : `fs_commands.rs` compile par `include_str!` les deux CSV de `src/__fixtures__/samples/`, hors de ces chemins ; une PR qui les renomme, les déplace ou les supprime casse `cargo check` sans que `check-rust` tourne. — `CLAUDE.md` « CI/CD », `src-tauri/src/commands/fs_commands.rs`
- **Un filtre `paths:` n'est pas prouvé par une PR qui touche le chemin exact.** Le glob (`src-tauri/**`) l'est par une PR qui touche un autre fichier du dossier, comme la PR #344 pour `check-rust`. — skill `pr-review` (section CI), #232
- **CI Rust et revue en fork.** Un fork qui attend la CI pour poster son verdict peut s'arrêter sans rien publier (PR #303) : poster les constats, surveiller le statut depuis la session principale, relancer la revue au vert. `check-rust` prend environ 3 min 30 avec le cache et 10 min à froid, sans compter la file du runner. — skill `pr-review` (délégation), #234
- **Un vert de `check-rust` ne dit rien de l'audit** : son étape `cargo audit` est `continue-on-error`. Lire le journal : `cargo-audit` écrit `1 vulnerability found` mais `N vulnerabilities found` (chercher `vulnerabilit(y|ies) found`), et le journal de `check-rust` contient des codes ANSI à retirer avant tout `grep`. Un `audit.yml` vert veut dire « zéro advisory hors de la liste acceptée », pas « zéro advisory ». — `CLAUDE.md` « CI/CD », [ADR 0018](docs/adr/0018-suppression-advisories-non-atteignables.md), #447
- **Windows n'est compilé qu'à la release.** La CI des PR ne compile que pour Linux : sans `cargo xwin check`, une casse Windows apparaît le jour du tag. — `docs/architecture.md` « `release.yml` — Build et publication », #455
- **Tests sans jsdom.** Un rendu par `renderToStaticMarkup` ne lance ni effet ni clic : ce qui en dépend n'est prouvé que par un test qui passe par `src/test/hookHarness.ts` (effets joués par `commit()`, gestionnaires appelés depuis l'arbre rendu). Le harnais ne rend pas de nouveau après un setter et laisse les enfants à l'état d'éléments : lire ce que le test exerce avant de le créditer. — `docs/architecture.md` « Tests (vitest sans jsdom) »
- **Faux `t`.** Un faux `t` qui rend la clé brute rend invisible un `t` oublié : les tests prennent `src/test/i18nMock.ts`, gardé par `src/__integration__/i18n-mock-guard.test.ts`. Jusqu'à #453, aucune garde ne détecte une clé présente dans une seule langue : comparer `fr.json` et `en.json`. — `docs/architecture.md` « Faux `t` marqué », #389
- **Sous-chaîne du markup.** `not.toContain("disabled")` sur le HTML rendu bute sur une classe Tailwind (`disabled:opacity-50`) : tester l'attribut (`disabled=""`). — PR #452
- **Booléen lié en texte.** tauri-plugin-sql stocke un booléen JS en texte `'true'`, qu'une condition `= 1` ne trouve pas : défaut resté invisible de la v0.1.0 à #390. — [ADR 0021](docs/adr/0021-migrations-profils-secondaires.md)
- **Transaction hors verrou.** Un `BEGIN` et son `COMMIT` envoyés par deux `db.execute` peuvent partir sur deux connexions du pool et laisser un verrou zombie (« database is locked », v0.10.0) : toujours `withTransaction` et son handle. — `docs/architecture.md` « Accès DB et transactions »
- **Dépôt public.** Aucune coordonnée d'un tiers (courriel, adresse, téléphone), aucune clé de licence ni aucun jeton dans un fichier commité, `STATE.md`, `docs/` et `.claude/skills/` compris ; un testeur se désigne par son identifiant Logto. — `.claude/skills/issue-license/SKILL.md`

View file

@ -1,8 +1,8 @@
# STATE — Simpl'Résultat
> Derniere MAJ : 2026-10-17 (**#369 mergée et fermée — la Répartition réservée à l'édition Base, PR #452 en avance rapide par l'API, `main` `a8c3fd6`.** En Gratuite, le bouton de la page Transactions porte un cadenas et ouvre l'écran de déverrouillage, jamais la fenêtre de répartition ; les répartitions existantes restent comptées. Suivis #451 et #454. **`audit.yml` rouge depuis le 2026-10-17 (run 558)** : trois avis sur `zip` 4.6.1, tiré pour Windows seulement par `tauri-plugin-updater`, à trancher avant le prochain tag. Aucune migration (v1→v17).)
> Derniere MAJ : 2026-10-18 (**#456 mergée et fermée — `REVIEW.md` à la racine, PR #460 en avance rapide par l'API, `main` `24da72b`.** Niveaux de revue calibrés (3/28/8 sur les 39 PR de référence, 6/33/12 sur les 51 de #344 à #452), règles par zone sourcées, commandes de vérification, pièges ; premier travail du pilote « évolution de `/pr-review` » (#457, #458). Suivis #461 et #462. **`audit.yml` toujours rouge (runs 558 et 561)** : correctif prêt dans la PR #459 (#455), en attente de `/pr-review`. Aucune migration (v1→v17).)
>
> Précédente MAJ : 2026-10-16 (**#447 mergée et fermée — `rustls` 0.23.45 → 0.23.46, PR #448 en avance rapide par l'API, `main` `1763a9f`.** RUSTSEC-2026-0346 à -0349, non atteignables, rougissaient `audit.yml` depuis le 2026-10-15 ; vert de nouveau sur `main` (run 556). Pas de release corrective, le cycle réel de mise à jour est dû au prochain tag. Suivis #449 et agent-defenseurs#186. Aucune migration (v1→v17).)
> Précédente MAJ : 2026-10-17 (**#369 mergée et fermée — la Répartition réservée à l'édition Base, PR #452 en avance rapide par l'API, `main` `a8c3fd6`.** En Gratuite, le bouton de la page Transactions porte un cadenas et ouvre l'écran de déverrouillage, jamais la fenêtre de répartition ; les répartitions existantes restent comptées. Suivis #451 et #454. **`audit.yml` rouge depuis le 2026-10-17 (run 558)** : trois avis sur `zip` 4.6.1, tiré pour Windows seulement par `tauri-plugin-updater`, à trancher avant le prochain tag. Aucune migration (v1→v17).)
## Position actuelle
@ -42,8 +42,11 @@ Audit critique de la page Bilan livré (`docs/audit-bilan-2026-05.md`, revue CPA
**#369 mergée sur `main`** (2026-10-17, `a8c3fd6`, pas encore publiée) — la Répartition (bouton de la page Transactions) est réservée à l'édition Base, comme l'écran de déverrouillage le promettait (`upsell.features.adjustments`) : en Gratuite, le bouton garde sa place, porte un cadenas et ouvre l'écran verrouillé dans une fenêtre (`UpsellDialog`) au lieu de la fenêtre de répartition, pour toute ligne ; tant que la licence n'est pas `ready`, bouton inactif, ni cadenas ni fenêtre ; les répartitions existantes restent comptées et gardent l'icône orange. Pièces : `splitAccessFor` (`src/shared/splitGate.ts`, `open`/`locked`/`pending`, plus strict que `profileGate` : `allowed` ne court-circuite pas `ready`) et la constante `SPLIT_FEATURE`, `SplitButton`, `UpsellDialog`, prop `splitAccess` obligatoire de `TransactionTable`, calculée par `TransactionsPage` depuis `useEntitlement(SPLIT_FEATURE)` ; ADR 0017 amendé, guide, docs intégrées FR/EN et `architecture.md` à jour, `[Unreleased]` porte un Modifié de plus. Suivis : #451 (fenêtre d'incitation de `ProfileSwitcher` sur `UpsellDialog`, Échap, focus) et #454 (reclasser une transaction répartie laisse sa ligne de compensation dans l'ancienne catégorie, irréparable en Gratuite depuis #369). Aucune migration (v1→v17).
**`REVIEW.md` mergé sur `main`** (2026-10-18, `24da72b`) — niveau d'une PR (haut, normal, bas) fixé par les fichiers que touche son diff, jamais par son auteur ; tableau Haut de 21 lignes (licence et jetons, migrations et bases de profils, release et code exécuté avec la clé de signature, dépendances qui changent la confiance, CI qui pourrait passer au vert à tort, `.claude/settings.json` et `.mcp.json`, et une règle qui échoue fermée pour tout fichier ajouté, renommé ou supprimé à la racine ou directement dans `src-tauri/`) ; neuf zones, chaque règle sourcée par une section du `CLAUDE.md` ou un ADR ; commandes de vérification ; onze pièges, dont les trois du skill global `pr-review`, corrigés pour ce dépôt ; script de calibration versionné `scripts/review-levels.py` (`--check` compare ses motifs au tableau). `CLAUDE.md` y renvoie. `/pr-review` ne le lira qu'après #457 (sur `main`, pas sur la base de la PR) ; le pilote #458 mesure les 20 PR suivantes. Suivis : #461 et #462. Aucune migration (v1→v17).
## Decisions recentes
- 2026-10-18 : **#456 mergée — `/fix-issue` (contrôle du plan refusé trois fois, chaque fois sur des points réels, derniers points appliqués, poursuite décidée par Max) → deux `/pr-review` APPROVE, le second après application des suggestions du premier → merge ff par l'API (PR #460, `main` `24da72b`) : `REVIEW.md` à la racine.** Calibration sur les PR mergées de #344 à #452 : 3/28/8 sur les 39 PR de référence (20,5 % de niveau haut pour une cible de 20 % ; écart justifié, les huit touchent la licence, les bases de profils et les migrations, ou `release.yml`, qui détient la clé de signature), contre 3/19/17 avec les motifs d'essai du document de départ, et 6/33/12 sur les 51 de la plage. Les « 39 dernières PR » de ce document venaient d'un tri par date de mise à jour (`limit=40&sort=recentupdate`) : liste figée dans la PR. **Décisions de Max** : une montée de version d'un paquet déjà présent dans un lock reste de niveau normal, correctifs d'advisory compris (2026-10-17) ; `db.ts` et `dataExportService.ts` restent normaux même quand le diff touche le verrou ou la restauration (2026-10-18). Preuves : script relancé à neuf contre l'API à chaque changement de règle, niveaux identiques PR par PR ; `--check` rouge sur une copie du tableau privée d'un motif ; CI `check-frontend` 562 verte (134 fichiers, 2248 tests). **Acquis** : l'API Forgejo marque un fichier modifié `changed`, pas `modified` (un test sur `modified` mettait toutes les PR de lock en haut) ; un contrôle de contenu de workflow doit ignorer les commentaires (#375 sortait haute sur le mot « toolchain ») ; les pages de l'API plafonnent à 50 éléments. Suivis : #461 (CSV de `src/__fixtures__/samples/` compilés par `include_str!` hors des `paths` de `check-rust`, `cargo-xwin` et toolchain non épinglés dans `release.yml`, commentaire périmé de `release.yml:104`), #462 (suggestions de la seconde revue, entrées du pilote #458), commentaire sur #457 (lecture sur `main`). Aucune migration (v1→v17). (ref #456, PR #460, #461, #462, #457, #458)
- 2026-10-17 : **#369 mergée — `/fix-issue` (plan check refusé une fois) → `/pr-review` APPROVE → deux commits de suivi en tête → merge ff par l'API (PR #452, `main` `a8c3fd6`) : la Répartition réservée à l'édition Base.** L'écran de déverrouillage la rangeait déjà en Base (`upsell.features.adjustments`) alors que l'app la laissait ouverte en Gratuite ; désormais, en Gratuite, le bouton garde sa place, porte un cadenas et ouvre l'écran verrouillé (`UpsellDialog`) pour toute ligne, jamais la fenêtre de répartition ; bouton inactif tant que la licence n'est pas `ready` ; répartitions existantes comptées, icône orange gardée. `splitAccessFor` est plus strict que `profileGate` (`allowed` ne court-circuite pas `ready`) ; `TransactionTable` ne rend pas la fenêtre de répartition en `locked` mais la garde pendant un rafraîchissement de licence, et ne rend pas l'incitation en `open`. Refus du plan check (MAJOR) : le câblage de `TransactionTable` n'était pas testé, d'où `TransactionTable.wiring.test.ts` sur le harnais `hookHarness` de #409 ; second passage : pass. Preuves : 21 mutations de l'auteur, toutes rouges ; revue (commentaire 24129) avec 14 des 15 mutations du relecteur rouges, la survivante R14 (un clic verrouillé qui ouvrirait aussi la fenêtre de répartition) épinglée par `d5d0e89` ; `a8c3fd6` réunit la clé de la Répartition dans `SPLIT_FEATURE` ; ces deux commits en tête, non relus, sont nommés dans le commentaire 24148 ; tip en worktree `npm ci` propre (sonde rc=1, build, 2248 vitest sur 134 fichiers, contre 2222) ; CI 557 et 559 ; #369 fermée par le merge. Site : le tableau comparatif de la-compagnie-maximus#180 (PR #298 de ce dépôt-là), en production depuis le 2026-10-16, range déjà la Répartition en Base, en avance sur l'app, décalage accepté par Max ; commentaire 24164 posé le 2026-10-17 (mergé, publié avec la prochaine release, pas encore disponible). Suivis : #451 (incitation de `ProfileSwitcher` sur `UpsellDialog`, Échap, focus) et #454 (`type:bug`, compensation d'une transaction répartie laissée dans l'ancienne catégorie au reclassement, car `updateTransactionCategory` ne touche que le parent ; rare, mais irréparable en Gratuite depuis #369). Aucune migration (v1→v17). (ref #369, PR #452, #451, #454, la-compagnie-maximus#180)
- 2026-10-16 : **#447 mergée — `/fix-issue` (plan check refusé une fois) → `/pr-review` APPROVE → merge ff par l'API (PR #448, `main` `1763a9f`) : `rustls` 0.23.45 → 0.23.46, corrigeant RUSTSEC-2026-0346 à -0349.** Publiés le 2026-10-14, les quatre avis rougissaient `audit.yml` (runs 553 et 555) sans que le lock ait bougé depuis la v0.17.1 ; repérés par la Phase 5.6 du `/warmup` le 2026-10-15, pas par le Défenseur, dont le check `deps` ne lance jamais `cargo audit` (**décision de Max** le 2026-10-15 : sous-check `cargo-audit`, agent-defenseurs#186). Aucun n'est atteignable (`rustls` atteint par `tauri-plugin-updater` → `reqwest` 0.13 seulement ; fournisseur `ring` sans groupe post-quantique, `brotli` non compilée, ni ECH ni `ClientSessionStore` maison), mais la règle 1 de `.cargo/audit.toml` dit corriger, pas supprimer. Diff : deux lignes de `Cargo.lock`, aucun bump compagnon, aucune arête `windows-sys` déplacée, une puce Sécurité par CHANGELOG. Refus du plan check (MAJOR) : « No behaviour change » avait déjà été recadré par `9abf474` au bump précédent, et la 0.23.46 apporte une dizaine d'autres corrections de la couche TLS (21 fichiers de `src/` en amont), donc l'entrée restreint « aucun changement » au code de l'application ; second passage : pass. Preuves : `cargo-audit` 0.22.2 (binaire de la CI) rouge sur l'arbre intact (4 IDs) puis vert ; `check-rust` run 554 lu ligne à ligne (gardes 2/2 et 9/9, 267 tests, 0 vulnérabilité ; à froid, rustc 1.99.1 du 2026-10-13 ayant changé la clé du cache) ; revue (commentaire 23853) avec mutation inverse rouge sur les quatre IDs ; tip en worktree propre (`npm ci`, sonde rc=1, build, 2222 vitest, `cargo test --locked` à 267, gardes, audit exit 0) ; après le merge, `audit.yml` en `workflow_dispatch` sur `main` : run 556 vert (666 crates, 12 warnings autorisés, 0 vulnérabilité). PR en `Refs #447` (critère post-merge), d'où une fermeture à la main (commentaire 23894). Pas de release corrective (décision de Max) : le bump part avec le prochain tag, cycle réel de mise à jour compris. **Acquis** : `cargo-audit` écrit « 1 vulnerability found! » mais « N vulnerabilities found! », chercher `vulnerabilit(y|ies) found` ou `error: [0-9]+ vulnerabilit` ; le journal de `check-rust` porte des codes ANSI (`CARGO_TERM_COLOR=always`) à retirer avant tout `grep`. Suivi #449 (`--locked` absent de `check-rust.yml` et de `release.yml`). Aucune migration (v1→v17). (ref #447, PR #448, #449, agent-defenseurs#186)
- 2026-10-15 : **Milestone `planned-2026-10-12-retours-onboarding-v0171` mergée — `/autopilot` (8 workers en série, 1 h 14, worker N+1 lancé pendant la CI du maillon N, CI lue à chaque maillon, runs 543-551) → huit `/pr-review` APPROVE en parallèle (aucun bloquant) → merge ff par l'API, chaque enfant reciblé sur `main` (`main` `934bcbf`, 8 PRs `merged`).** Tip éprouvé en worktree `npm ci` propre : sonde rc=1, build, 2222 vitest (2065 avant), 267 Rust. 31 décisions HIGH, 25 MEDIUM, 0 LOW ; mutations des workers et des relecteurs presque toutes rouges, les survivantes sans effet de comportement ou épinglées. **Acquis** : deux workers sur huit ont perdu leur implémentation non commitée au `git checkout --` de la passe de mutations → consigne « commit avant la première mutation » ajoutée en cours de run, zéro incident ensuite ; les corps de PR des workers ne portent pas `Resolves #N` en clair (seulement les commits), sans effet sur un merge ff. Trou global relevé par deux revues : aucun test ne détecte une clé i18n présente dans une seule langue (#446). Aucune migration (v1→v17). (ref #424, #422, #427, #426, #430, #431, #425, #432, PRs #438-#445, #446)
@ -86,11 +89,10 @@ Audit critique de la page Bilan livré (`docs/audit-bilan-2026-05.md`, revue CPA
- tête éprouvée dans un worktree propre : sonde rc=1, 1520 vitest, 255 Rust, CI 495 et 496.
- **Suite** : #390 reste ouverte en `status:blocked` jusqu'à la liste post-merge.
- Aucune migration ajoutée (v1→v17). (ref #390, PR #394, #395, ADR 0021)
- 2026-10-05 : **v0.17.0 taggée et publiée (`07255e1`, run 491 vert) ; #234 close ; textes de la-compagnie-maximus#184 postés.** Pré-vol du skill sur le tip `8f964e0` : build, 1498 vitest, `cargo check` sans avertissement, 224 Rust ; `.claude/worktrees/` vide. Avant le bump, diff vide depuis `v0.16.0` sur `Cargo.lock`, `Cargo.toml`, `release.yml`, `tauri.conf.json` et `package*.json`, et aucune ligne du diff ne touche le gating par édition : ni clés testeurs à émettre, ni cycle de mise à jour réel exigé (étape 10), la raison étant écrite dans le corps de la release plutôt que tue. Version mineure (fonctionnalités ajoutées) : 5 manifestes, 2 changelogs et la ligne de `CLAUDE.md`, `Cargo.lock` édité à la main et accepté par `cargo metadata --locked`. Tag annoté confirmé par Max, poussé seul (jamais `--tags`). Run 491 en 22 min ; `verify-release.sh` vert (7 artefacts, signatures et URL en 0.17.0) ; l'endpoint de `tauri.conf.json` (registre de paquets) sert aussi la 0.17.0. Le même jour : **#234 fermée** sur sa quatrième preuve (`actcache` à 999 Mo, une seule entrée : celle, isolée, de la PR #375 a été évincée par le ménage par clé), avec la milestone `spec-ci-build-optimization` (4/4) ; la-compagnie-maximus#184 reçoit les deux textes des suites de licence (contrôle à la saisie, retrait de la clé), rattachés aux deux passages de son corps que la v0.17.0 rend faux. Aucune migration DB (v1→v17). (ref v0.17.0, #234, #373, #370, #367, la-compagnie-maximus#184)
## Blockers actifs
- Aucun blocker externe dur. `spec-monetisation` **fermée 12/12** (2026-07-08) — #50/#52/#53/#135/#136 livrées, ne sont plus des blockers.
- **`audit.yml` rouge depuis le 2026-10-17 06:00 UTC, à trancher avant le prochain tag** (run 558 sur `065f580`, base d'avis passée de 1329 à 1334) : trois avis sur `zip` 4.6.1, à l'extraction d'une archive (liens symboliques, bits SUID/SGID), RUSTSEC-2026-0367 (élevée, 7.3), RUSTSEC-2026-0368 et RUSTSEC-2026-0369 (critique, 9.1). `zip` n'est compilé que pour Windows, tiré par `tauri-plugin-updater` 2.10.0 via sa feature par défaut `zip` (qui active aussi `tar` et `flate2`) ; il n'est corrigé qu'en `zip` >= 9.0.0, aucun 4.x corrigé, et `tauri-plugin-updater` 2.14.0 exige toujours `zip ^4`. L'extraction n'a lieu que sur un paquet zip dont la signature a été vérifiée (`download` vérifie avant `install`), et nos paquets Windows sont des `.exe` NSIS. Piste : déclarer l'updater sans ses features par défaut en gardant `rustls-tls`, ce qui retirerait `zip` (et `tar`, canari de la garde des suppressions de `check-rust`, à remplacer). Issue à ouvrir sur décision de Max. #447 reste fermée à bon droit : ses quatre identifiants n'apparaissent plus dans le run 558.
- **`audit.yml` rouge depuis le 2026-10-17 06:00 UTC, toujours rouge, à régler avant le prochain tag** (runs 558 sur `065f580` et 561 sur `589aee0`, base d'avis passée de 1329 à 1334, puis à 1338) : trois avis sur `zip` 4.6.1, à l'extraction d'une archive (liens symboliques, bits SUID/SGID), RUSTSEC-2026-0367 (élevée, 7.3), RUSTSEC-2026-0368 et RUSTSEC-2026-0369 (critique, 9.1). `zip` n'est compilé que pour Windows, tiré par `tauri-plugin-updater` 2.10.0 via sa feature par défaut `zip` (qui active aussi `tar` et `flate2`) ; il n'est corrigé qu'en `zip` >= 9.0.0, aucun 4.x corrigé, et `tauri-plugin-updater` 2.14.0 exige toujours `zip ^4`. L'extraction n'a lieu que sur un paquet zip dont la signature a été vérifiée (`download` vérifie avant `install`), et nos paquets Windows sont des `.exe` NSIS. **#455 ouverte et analysée ; la PR #459 est prête, CI verte (run 560), en attente de `/pr-review`** (première PR mesurée du pilote #458, de niveau haut) : updater compilé sans sa feature `zip` (`default-features = false` avec `rustls-tls`), canari de la garde des suppressions de `check-rust` passé de `tar` à `webkit2gtk`, preuve Windows par `cargo xwin check`. Depuis le run 561 (2026-10-18), 18 avertissements autorisés au lieu de 12 : six avertissements `unmaintained` parus entre-temps, RUSTSEC-2026-0370 à -0372 (`zvariant`, `zbus_names`, `zvariant_derive`, deux versions chacun), non bloquants ; la checklist d'après merge de #459 a été corrigée en conséquence. #447 reste fermée à bon droit : ses quatre identifiants n'apparaissent ni dans le run 558 ni dans le run 561.
- **Avant le prochain tag : #419, point 1.** Sous jq 1.6, la version du conteneur de release, `scripts/check-latest-json.jq` accepte un `latest.json` vide ou à deux documents : c'est la garde du `DELETE` du registre. Inatteignable aujourd'hui (la génération échoue fermée avant « Publish »), mais à fermer avant de tagger.
- **Vérifications dues à la prochaine release** (après le tag, étape 9 de `/release`) :
- Retours d'onboarding v0.17.1 (#438-#445), dans l'app : cas de la section « Retours de la v0.17.1 » de `docs/qa-onboarding.html` et parcours guidé à huit étapes, en particulier `v0171-import-source-reopen`, `v0171-budget-card-leaves`, l'encadré « Structure attendue » dans les deux thèmes et la première ouverture d'un profil qui avait fini le parcours (#446).

393
scripts/review-levels.py Executable file
View file

@ -0,0 +1,393 @@
#!/usr/bin/env python3
"""Replay the review levels of REVIEW.md on merged pull requests (#456).
Usage:
python3 scripts/review-levels.py [FIRST LAST] (default: 344 452)
python3 scripts/review-levels.py --check (no network)
The first form reads the Forgejo API (token in $FORGEJO_TOKEN, else
~/.forgejo-token; read-only calls) and prints each merged PR of the range
with its trial level (pilot document, section 4.3) and its REVIEW.md level,
then the distributions for the 39 reference PRs and for the whole range.
--check compares the patterns of this script with the first column of the
Haut table of REVIEW.md and exits 1 if they differ: a PR that changes the
Niveaux section updates this script in the same diff.
The content conditions of the Haut table are approximated from the diff:
lib.rs (only generate_handler![] entries are exempt), Tauri configuration
and manifests (only the app's version is exempt), lock files (a package
added or removed, an origin changed), check workflows (a change that can
turn a failing check green, read against the PR's merge base), network
calls (reqwest in Rust, fetch to an absolute URL in TS), consent files.
"""
import itertools
import json
import os
import re
import sys
import urllib.request
API = "https://git.lacompagniemaximus.com/api/v1/repos/maximus/simpl-resultat"
REVIEW_MD = os.path.join(os.path.dirname(os.path.abspath(__file__)), "..", "REVIEW.md")
# The 39 reference PRs of the pilot document, frozen by number: the listing
# that produced them (closed PRs, limit=40, sort=recentupdate) is not stable.
REF39 = [344, 375, 377, 378, 379, 380, 381, 382, 383, 386, 387, 388, 394, 402, 403,
407, 412, 413, 414, 415, 416, 417, 418, 420, 428, 433, 434, 435, 436, 438,
439, 440, 441, 442, 443, 444, 445, 448, 452]
TRIAL_HIGH = [
"src-tauri/src/commands/{auth_commands,token_store,license_commands,entitlements,account_cache,backup_commands,fs_commands,export_import_commands}.rs",
"src-tauri/src/lib.rs", "src-tauri/src/database/**", "src-tauri/tauri.conf.json",
"src-tauri/capabilities/**", ".forgejo/workflows/**", ".cargo/**", "src-tauri/Cargo.{toml,lock}",
"package.json", "package-lock.json",
"src/services/{authService,licenseService,db,feedbackService,dataExportService}.ts",
"src/services/entitlements*", "scripts/build-latest-json*",
]
TRIAL_LOW = ["**/*.md", "docs/**", "reports/**", "tasks/**", "public/CHANGELOG*", "LICENSE"]
# REVIEW.md, table Haut: rows without a condition.
HIGH_ALWAYS = [
"src-tauri/src/database/**",
"src-tauri/src/commands/profile_commands.rs",
"src-tauri/src/commands/{license_commands,entitlements,auth_commands,token_store,account_cache}.rs",
"src/services/{licenseService,authService}.ts",
".claude/skills/issue-license/**",
"src-tauri/src/commands/export_import_commands.rs",
"src-tauri/src/commands/feedback_commands.rs", "src/services/feedbackService.ts",
"src-tauri/capabilities/**",
".forgejo/workflows/release.yml", "scripts/build-latest-json*", "scripts/check-latest-json.jq",
".claude/skills/release/**",
"src-tauri/build.rs", "{vite,vitest,postcss}.config.*", ".postcssrc*", ".npmrc", "**/rust-toolchain*",
"Cargo.{toml,lock}",
"**/.cargo/**",
".claude/settings.json", ".mcp.json",
]
CONSENT_FILES = {"src/components/balance/PriceFetchControl.tsx",
"src/components/settings/PriceFetchConsentToggle.tsx"}
TAURI_CONFIG = ["src-tauri/{tauri*.conf.json*,Tauri*.toml}"]
CHECK_WORKFLOWS = {".forgejo/workflows/check-rust.yml", ".forgejo/workflows/check-frontend.yml"}
SHIPPED = ["src/**", "src-tauri/src/**", "index.html", "public/**"]
LOW = ["**/*.md", "docs/**", "LICENSE", "tasks/**", "reports/**"]
NEVER_LOW = ["CLAUDE.md", "REVIEW.md", "docs/adr/**", "docs/architecture.md", "docs/qa-update-cycle.md", ".claude/**"]
def token():
return os.environ.get("FORGEJO_TOKEN") or open(os.path.expanduser("~/.forgejo-token")).read().strip()
def get(path, raw=False):
req = urllib.request.Request(API + path, headers={"Authorization": "token " + token()})
data = urllib.request.urlopen(req).read()
return data if raw else json.loads(data)
def expand_braces(pat):
m = re.search(r"\{([^{}]*)\}", pat)
if not m:
return [pat]
return list(itertools.chain.from_iterable(
expand_braces(pat[:m.start()] + alt + pat[m.end():]) for alt in m.group(1).split(",")))
def glob_re(pat):
# '**/' spans zero or more directories, '*' and '?' never cross '/'
i, out = 0, ""
while i < len(pat):
if pat.startswith("**/", i):
out += "(?:.*/)?"; i += 3; continue
if pat.startswith("**", i):
out += ".*"; i += 2; continue
c = pat[i]
out += "[^/]*" if c == "*" else "[^/]" if c == "?" else re.escape(c)
i += 1
return re.compile("^" + out + "$")
def match(pats, path):
# '{a,b}' alternates, wildcards allowed inside
return any(glob_re(p).match(path) for pat in pats for p in expand_braces(pat))
def file_blocks(diff):
out = {}
for block in re.split(r"(?m)^diff --git ", diff)[1:]:
out[block.split("\n", 1)[0].split(" b/")[-1]] = block
return out
def meaningful(text):
t = text.strip()
return t != "" and not t.startswith(("#", "//"))
def changed(block):
return [l for l in block.split("\n")
if l[:1] in "+-" and not l.startswith(("+++", "---")) and meaningful(l[1:])]
def lock_adds_or_removes(name, block):
cargo = name.endswith("Cargo.lock")
lines = block.split("\n")
if cargo and any(re.match(r'^[+-]name = ', l) for l in lines):
return True
if not cargo and any(re.match(r'^[+-]\s+"node_modules/[^"]+": \{', l) for l in lines):
return True
source = r'^([+-])source = "([^"]+)"' if cargo else r'^([+-])\s+"resolved": "([^"]+)"'
digest = r'^\+(checksum) = ' if cargo else r'^\+\s+"(integrity)": '
version = r'^\+version = ' if cargo else r'^\+\s+"version": '
for hunk in re.split(r"(?m)^@@", block)[1:]:
hl = hunk.split("\n")
# an origin whose host or scheme changes, with or without a new version
before, after = set(), set()
for l in hl:
m = re.match(source, l)
if m:
(before if m.group(1) == "-" else after).add(origin_of(m.group(2)))
if after - before:
return True
# a checksum or integrity that changes without a new version
if any(re.match(digest, l) for l in hl) and not any(re.match(version, l) for l in hl):
return True
return False
def origin_of(url):
m = re.match(r"^([a-z+]+)://([^/]+)", url)
return m.group(1) + "://" + m.group(2) if m else url
STEP = re.compile(r"^\s*- (name|uses):")
VERDICT = re.compile(r"npm ci|npm run build|npm test|cargo check|cargo test|cargo audit|cargo-audit|Verify", re.I)
def regions(base):
lines = base.split("\n")
region, top, job_key = {}, None, None
for i, l in enumerate(lines, 1):
m = re.match(r"^([A-Za-z_][\w-]*):", l)
if m:
top = m.group(1)
m = re.match(r"^ ([A-Za-z_][\w-]*):", l)
if m:
job_key = m.group(1)
elif re.match(r"^\S", l) or re.match(r"^ \S", l):
job_key = None
if top in ("on", "true"):
region[i] = "on"
elif top == "permissions" or job_key in ("env", "container", "permissions"):
region[i] = "jobcfg"
else:
region[i] = "other"
starts = [i for i, l in enumerate(lines, 1) if STEP.match(l)]
for k, s in enumerate(starts):
e = starts[k + 1] - 1 if k + 1 < len(starts) else len(lines)
if VERDICT.search("\n".join(lines[s - 1:e])):
for i in range(s, e + 1):
if region[i] == "other":
region[i] = "verdict"
return region, lines
def weakening(block, base):
"""Ways a check-workflow diff could turn a failing check green (empty = none)."""
region, lines = regions(base)
reasons, old, plus = set(), 0, []
def flush(pos):
texts = [p[1:] for p in plus]
active = [t for t in texts if meaningful(t)] # comments never count
if any(re.search(r"continue-on-error|\|\|\s*true", t) for t in active):
reasons.add("continue-on-error or || true added")
if any(re.search(r"GITHUB_ENV|GITHUB_PATH|npm pkg set|git checkout|git restore", t) for t in active):
reasons.add("writes the job environment or the repository")
if any(re.search(r"^\s*(- )?uses:", t) and not re.search(r"Swatinem/rust-cache|actions/cache", t) for t in active):
reasons.add("action added (not a cache)")
if any(re.search(r"rustup|toolchain|\bPATH\b|/bin/", t) for t in active):
reasons.add("install step changes the toolchain or the PATH")
if any(re.match(r"^(\s{4})?(if|defaults):", t) for t in active):
reasons.add("job-level if/defaults changed")
first = next((t for t in texts if meaningful(t)), None)
if first is None or STEP.match(first):
return
prev, nxt = region.get(pos - 1, "other"), region.get(pos, "other")
if "on" in (prev, nxt):
if any(re.search(r"branches|paths-ignore", t) for t in active):
reasons.add("trigger narrowed")
j = pos - 1
while j > 0 and not re.match(r"^\s*[\w-]+:", lines[j - 1]):
j -= 1
if j > 0 and "paths-ignore" in lines[j - 1] and any(re.match(r"^\s*- ", t) for t in active):
reasons.add("paths-ignore entry added")
elif prev == nxt == "verdict":
reasons.add("verdict step modified")
elif "jobcfg" in (prev, nxt):
reasons.add("job env/container/permissions modified")
for line in block.split("\n"):
m = re.match(r"^@@ -(\d+)(?:,\d+)? \+\d+(?:,\d+)? @@", line)
if m:
flush(old); plus = []
old = int(m.group(1)); continue
if line.startswith("+") and not line.startswith("+++"):
plus.append(line); continue
flush(old); plus = []
if line.startswith("-") and not line.startswith("---"):
if meaningful(line[1:]):
if re.match(r"^(\s{4})?(if|defaults):", line[1:]):
reasons.add("job-level if/defaults changed")
if re.search(r"rustup|toolchain|\bPATH\b|/bin/", line[1:]):
reasons.add("install step changes the toolchain or the PATH")
r = region.get(old, "other")
if r == "on":
reasons.add("trigger removed or changed")
elif r == "verdict":
reasons.add("verdict step modified or removed")
elif r == "jobcfg":
reasons.add("job env/container/permissions modified")
old += 1
elif line.startswith(" "):
old += 1
flush(old)
return sorted(reasons)
def adds_network_call(path, block):
if not match(SHIPPED, path) or re.search(r"test", path):
return False
for l in block.split("\n"):
if l.startswith("+") and not l.startswith("+++"):
if path.endswith(".rs") and "reqwest" in l:
return True
if re.search(r"\.(ts|tsx)$", path) and re.search(r"fetch\(\s*[`'\"]https?://", l):
return True
return False
def trial_level(paths):
if any(match(TRIAL_HIGH, p) for p in paths):
return "haut"
return "bas" if all(match(TRIAL_LOW, p) for p in paths) else "normal"
def review_level(number, files, diff, merge_base):
blocks = file_blocks(diff)
triggers = []
for f in files:
b = blocks.get(f["filename"], "")
for path in filter(None, {f["filename"], f.get("previous_filename")}):
if match(HIGH_ALWAYS, path):
triggers.append(path)
elif path == "src-tauri/src/lib.rs":
if [l for l in changed(b) if not re.match(r"^[+-]\s*commands::\w+,?\s*$", l)]:
triggers.append(path)
elif path == "src-tauri/src/commands/balance_commands.rs":
hunks = re.split(r"(?m)^(?=@@)", b)[1:]
if any(re.search(r"fetch_price|activation", h.split("\n")[0]) or
any(re.search(r"fetch_price|activation", l) for l in changed(h)) for h in hunks):
triggers.append(path)
elif match(TAURI_CONFIG, path) or path in ("src-tauri/Cargo.toml", "package.json"):
if [l for l in changed(b) if not re.match(r'^[+-]\s*"?version"?\s*[:=]', l)]:
triggers.append(path)
elif path in ("src-tauri/Cargo.lock", "package-lock.json"):
if lock_adds_or_removes(path, b):
triggers.append(path)
elif path == ".forgejo/workflows/audit.yml" and changed(b):
triggers.append(path)
elif path in CHECK_WORKFLOWS:
wf = path.split("/")[-1]
base = get(f"/raw/.forgejo/workflows/{wf}?ref={merge_base}", raw=True).decode()
if weakening(b, base):
triggers.append(path)
elif path.startswith(".forgejo/workflows/") and f["status"] == "added":
triggers.append(path + " (new workflow)")
elif path == "REVIEW.md":
triggers.append(path + " (check the « Niveaux » section by hand)")
if adds_network_call(path, b):
triggers.append(path + " (network call)")
added = [l for l in b.split("\n") if l.startswith("+") and not l.startswith("+++")]
if path.startswith(".forgejo/workflows/") and any(re.search(r"\bsecrets\b|pull_request_target", l) for l in added):
triggers.append(path + " (secrets or pull_request_target added)")
if path in CONSENT_FILES and any(re.search(r"consent", l, re.I) for l in changed(b)):
triggers.append(path + " (consent)")
parts = path.split("/")
top_level = len(parts) == 1 or (parts[0] == "src-tauri" and len(parts) == 2)
new_hidden = f["status"] == "added" and any(
x.startswith(".") for x in parts[:-1]) and parts[0] not in (".forgejo", ".claude", ".cargo", ".github")
# Forgejo reports an edited file as "changed"
if (f["status"] not in ("changed", "modified") and top_level and not path.endswith(".md")) or new_hidden:
triggers.append(path + " (" + f["status"] + " at top level)")
paths = [f["filename"] for f in files]
if triggers:
return "haut", sorted(set(triggers))
if all(match(LOW, p) and not match(NEVER_LOW, p) for p in paths):
return "bas", []
return "normal", []
CONDITIONAL = {
"src-tauri/src/lib.rs", "src-tauri/src/commands/balance_commands.rs",
"src-tauri/Cargo.toml", "package.json", "src-tauri/Cargo.lock", "package-lock.json",
".forgejo/workflows/**", ".forgejo/workflows/audit.yml",
".forgejo/workflows/{check-rust,check-frontend}.yml", "REVIEW.md",
}
def check():
"""Compare this script's patterns with the first column of the Haut table."""
text = open(REVIEW_MD, encoding="utf-8").read()
table = text.split("### Haut", 1)[1].split("\n\n", 2)[1]
documented = set()
for row in table.split("\n"):
if not row.startswith("| ") or row.startswith("| Motif") or row.startswith("| fichier à la racine"):
continue
documented.update(re.findall(r"`([^`]+)`", row.split(" | ")[0]))
coded = set(HIGH_ALWAYS) | set(TAURI_CONFIG) | set(SHIPPED) | CONDITIONAL
for label, extra in (("REVIEW.md only", documented - coded), ("script only", coded - documented)):
for pattern in sorted(extra):
print(f"{label}: {pattern}")
if documented != coded:
return 1
print(f"ok: {len(coded)} patterns, identical to the Haut table of REVIEW.md")
return 0
def main():
if sys.argv[1:] == ["--check"]:
sys.exit(check())
first, last = (int(sys.argv[1]), int(sys.argv[2])) if len(sys.argv) == 3 else (344, 452)
merged, page = [], 1
while True:
batch = get(f"/pulls?state=closed&limit=50&page={page}")
if not batch:
break
merged += [p for p in batch if p.get("merged") and first <= p["number"] <= last]
page += 1
merged.sort(key=lambda p: p["number"])
rows = []
for p in merged:
n = p["number"]
files, fpage = [], 1
while True:
# the instance caps a page at 50 items: read until an empty page
fb = get(f"/pulls/{n}/files?limit=50&page={fpage}")
if not fb:
break
files += fb
fpage += 1
diff = get(f"/pulls/{n}.diff", raw=True).decode("utf-8", "replace")
merge_base = get(f"/pulls/{n}")["merge_base"]
trial = trial_level([f["filename"] for f in files])
level, triggers = review_level(n, files, diff, merge_base)
rows.append((n, trial, level, triggers))
print(f"#{n}\t{'ref' if n in REF39 else ' '}\t{trial}\t{level}\t{'; '.join(triggers)}")
for label, keep in (("39 reference PRs", lambda n: n in REF39), (f"{len(rows)} PRs #{first}-#{last}", lambda n: True)):
sel = [r for r in rows if keep(r[0])]
for col, name in ((1, "trial"), (2, "REVIEW.md")):
counts = {lvl: sum(1 for r in sel if r[col] == lvl) for lvl in ("bas", "normal", "haut")}
share = 100 * counts["haut"] / len(sel)
print(f"{label}, {name}: {counts} -> haut {share:.1f} %")
if __name__ == "__main__":
main()