Compare commits

...

12 commits

Author SHA1 Message Date
le king fu
522232947c docs(release): report edition matrix changes to the site's comparison table
All checks were successful
PR Check — Frontend / frontend (pull_request) Successful in 2m3s
The product page of la-compagnie-maximus copies the edition matrix into a
comparison table (la-compagnie-maximus#180). Step 0 of the release skill now
says to prepare the site PR before the tag and deploy it only after the
release is published.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-18 14:18:17 -04:00
le king fu
c667ad0079 state: sync after #419
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-18 13:39:21 -04:00
le king fu
c859405deb fix(release): refuse an empty or multi-document latest.json, test the Publish step
All checks were successful
PR Check — Frontend / frontend (pull_request) Successful in 2m4s
Follow-ups of the review of PR #418.

- check-latest-json.jq wants exactly one document and release.yml calls it
  with -s: under jq 1.6, the release container's, an empty file passed the
  check (no output, status 0) and two documents were judged on the last one,
  so the job would have deleted the published manifest and uploaded the bad
  one. Two tests cover both cases.
- build-latest-json.test.mjs runs the text of release.yml's "Publish
  latest.json to package registry" step against a fake curl: DELETE then PUT
  on a valid manifest, exit 1 and no request on a refused one.
- check-frontend no longer ignores .claude/** and release.yml (decision of
  Max): the test reads verify-release.sh and runs the Publish step, so "on
  every pull request" holds. REVIEW.md, CLAUDE.md and architecture.md follow.
- release.yml: stale ".deb fallback" comment fixed.
- qa-update-cycle.md: a tauri dev build on Linux shows TargetsNotFound,
  expected since #418.

Refs #461

Resolves #419

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-18 13:00:40 -04:00
le king fu
201a9779fe state: sync after #455 and #457
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-18 12:50:31 -04:00
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
le king fu
589aee0b38 state: sync after #369
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-17 07:46:13 -04:00
le king fu
a8c3fd6997 refactor(gating): one constant for the split's feature key
All checks were successful
PR Check — Frontend / frontend (pull_request) Successful in 1m56s
Review of PR #452 (comment 24129): "adjustments" was written three
times for the split (the page's gate, the button's edition, the
dialog's locked screen). SPLIT_FEATURE in splitGate.ts is now the one
source, so moving the split to its own key or to Premium is a one-line
change. No behaviour change; setting it to "budget" turns 2 tests red.

Refs #369

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-17 07:36:33 -04:00
le king fu
d5d0e89d2c test(gating): pin that one click never arms the other window
Review of PR #452 (comment 24129): the upgrade to Base checked that the
upsell goes away, not that no split window appears in its place, so a
locked click that also armed the split window (R14) left the suite
green. Both edition changes now assert the other window stays closed,
during the license refresh and after it.

R14 now turns 1 test red, and its mirror (a split click that also arms
the upsell) 1 test red.

Refs #369

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-17 07:36:33 -04:00
le king fu
cb7f8479e4 feat(gating): reserve transaction splits for the Base edition
All checks were successful
PR Check — Frontend / frontend (pull_request) Successful in 2m16s
The Répartition (split) button of the Transactions page now belongs to
the `adjustments` feature, as the unlock screen already promised
(upsell.features.adjustments). On the Free edition the button keeps its
place and its icon, carries a lock and opens the locked screen in a
dialog instead of the split window, for every row, split or not. While
the license is not ready it shows no lock and does nothing. Existing
splits stay in the data, counted by the reports and flagged by the
orange icon.

- splitGate.ts: pure splitAccessFor(gate) -> open | locked | pending,
  stricter than profileGate: `allowed` does not short-circuit `ready`.
- SplitButton: stateless button that decides what a click opens.
- UpsellDialog: UpsellGate in a named modal dialog, closed by its X and
  by "I already have a key".
- TransactionTable: required splitAccess prop. The split window is not
  rendered once the edition resolves to Free, and stays mounted through
  a license refresh; the upsell is not rendered once it resolves to Base.
- TransactionsPage reads useEntitlement("adjustments").
- ADR 0017 amendment and matrix, user guide, in-app docs (FR, EN),
  architecture, entitlements header comment, both changelogs.

Tests: the predicate, the button's markup and click, the dialog, the
table's wiring through the shared hook harness (#409), the page under
the three gate states, and the new key in both locales.

Fixes #369

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 20:02:47 -04:00
33 changed files with 1508 additions and 158 deletions

View file

@ -17,6 +17,7 @@ updated: 2026-10-11
0. **Pré-vol — revalider le tip localement.** Les workflows `check-rust.yml` / `check-frontend.yml` ne tournent pas sur `main` : le tip mergé n'a jamais été vu par le CI (le dernier run vert portait sur la branche d'issue avant merge), et le tag grave ce tip exact dans des binaires distribués. Lancer `npm run build && npm test` (vitest) + `cd src-tauri && cargo check && cargo test`. Vérifier aussi que `.claude/worktrees/` est vide (worktrees leftover → vitest récurse et gonfle le compteur). Ne tagger que sur un tip vert. 0. **Pré-vol — revalider le tip localement.** Les workflows `check-rust.yml` / `check-frontend.yml` ne tournent pas sur `main` : le tip mergé n'a jamais été vu par le CI (le dernier run vert portait sur la branche d'issue avant merge), et le tag grave ce tip exact dans des binaires distribués. Lancer `npm run build && npm test` (vitest) + `cd src-tauri && cargo check && cargo test`. Vérifier aussi que `.claude/worktrees/` est vide (worktrees leftover → vitest récurse et gonfle le compteur). Ne tagger que sur un tip vert.
**Accès des testeurs.** Si `[Unreleased]` touche `FEATURE_TIERS`, le gating par édition ou `auto-update` : vérifier que chaque testeur (Max, Michel) a une clé Base+ **saisie avant le tag**, émise via `/issue-license` au besoin — la clé s'entre dans la version installée et survit à la mise à jour ; une installation sans clé reçoit cette version, puis plus rien. Cas réel v0.15.0 : deux clés Premium émises le jour même, juste avant le tag. **Accès des testeurs.** Si `[Unreleased]` touche `FEATURE_TIERS`, le gating par édition ou `auto-update` : vérifier que chaque testeur (Max, Michel) a une clé Base+ **saisie avant le tag**, émise via `/issue-license` au besoin — la clé s'entre dans la version installée et survit à la mise à jour ; une installation sans clé reçoit cette version, puis plus rien. Cas réel v0.15.0 : deux clés Premium émises le jour même, juste avant le tag.
**Tableau des éditions du site.** Si `[Unreleased]` change la matrice des éditions (`ENTITLEMENTS` de `src/shared/entitlements.ts`, `FEATURE_TIERS` de `src-tauri/src/commands/entitlements.rs`, ou un module Free qui passe sous verrou, comme le fractionnement de #369) : la page produit du site en a une copie (`la-compagnie-maximus/src/data/editions.ts`, tableau comparatif et phrase `simplresultat_detail_desc`). Préparer la PR du site avant le tag en suivant la section « Matrice des éditions — Simpl-Résultat » de son `CLAUDE.md`, et ne la déployer qu'**après** la publication de la release : avant, le tableau promettrait un verrou absent des binaires.
1. Determiner la nouvelle version (argument utilisateur ou demander) 1. Determiner la nouvelle version (argument utilisateur ou demander)
2. Bump version dans les 5 fichiers : 2. Bump version dans les 5 fichiers :
- `src-tauri/Cargo.toml` (ligne `version = "..."`) - `src-tauri/Cargo.toml` (ligne `version = "..."`)
@ -50,7 +51,7 @@ updated: 2026-10-11
bash .claude/skills/release/scripts/verify-release.sh vX.Y.Z bash .claude/skills/release/scripts/verify-release.sh vX.Y.Z
``` ```
Le script (token `~/.forgejo-token`) lit la release par tag, compte les 7 artefacts **par suffixe** (`.exe` NSIS, `.deb`, `.rpm`, leurs 3 signatures `.sig`, `latest.json` — les noms exacts varient selon la version de Tauri), puis télécharge `latest.json` (il pilote l'auto-update des installations existantes) et vérifie `version` (il affiche `pub_date` et la longueur des notes), puis exige **exactement trois clés** (#320) : `linux-x86_64-deb`, `linux-x86_64-rpm` et `windows-x86_64`, sans clé générique `linux-x86_64` depuis le 2026-10-11 (`docs/qa-update-cycle.md`). Pour chacune : l'extension de l'URL (`.deb`, `.rpm`, `.exe`), une signature non vide dont le `file:` (commentaire de confiance minisign, signature décodée du base64) est le nom de fichier de l'URL, décodé, et la version dans l'URL. **Il sort en 1 sur tout écart** (`MANQUANT`, `VIDE`, `MAUVAISE VERSION`, `MAUVAISE EXTENSION`, `DIFFÉRENT`, `INATTENDUE`) et finit par `VERDICT : OK` ou `VERDICT : ÉCHEC` : pas d'annonce de la release ni d'étape 10 avant un verdict OK. Un second argument (`verify-release.sh vX.Y.Z fichier.json`) contrôle un `latest.json` local au lieu de l'asset, pour éprouver les contrôles sur un fichier fabriqué. Le script (token `~/.forgejo-token`) lit la release par tag, compte les 7 artefacts **par suffixe** (`.exe` NSIS, `.deb`, `.rpm`, leurs 3 signatures `.sig`, `latest.json` — les noms exacts varient selon la version de Tauri), puis télécharge `latest.json` (il pilote l'auto-update des installations existantes) et vérifie `version` (il affiche `pub_date` et la longueur des notes), puis exige **exactement trois clés** (#320) : `linux-x86_64-deb`, `linux-x86_64-rpm` et `windows-x86_64`, sans clé générique `linux-x86_64` depuis le 2026-10-11 (`docs/qa-update-cycle.md`). Pour chacune : l'extension de l'URL (`.deb`, `.rpm`, `.exe`), une signature non vide dont le `file:` (commentaire de confiance minisign, signature décodée du base64) est le nom de fichier de l'URL, décodé, et la version dans l'URL. **Il sort en 1 sur tout écart** (`MANQUANT`, `VIDE`, `MAUVAISE VERSION`, `MAUVAISE EXTENSION`, `DIFFÉRENT`, `INATTENDUE`) et finit par `VERDICT : OK` ou `VERDICT : ÉCHEC` : pas d'annonce de la release ni d'étape 10 avant un verdict OK. Un second argument (`verify-release.sh vX.Y.Z fichier.json`) contrôle un `latest.json` local au lieu de l'asset, pour éprouver les contrôles sur un fichier fabriqué.
**Lire aussi l'étape « Generate latest.json » du journal du run** (même route web que ci-dessous) : `scripts/build-latest-json.sh` y nomme le paquet retenu pour chaque type (`build-latest-json: .rpm <- …`), puis le `latest.json` généré est affiché. Le script et le filtre `scripts/check-latest-json.jq` passent par les tests de `check-frontend` à chaque PR ; leur appel par `release.yml` et le délimiteur aléatoire des notes ne s'exercent qu'au tag. Un manifeste refusé par le filtre arrête le job sur `ERROR: release-assets/latest.json failed scripts/check-latest-json.jq`, avant le `DELETE` : registre intact, et la release Forgejo reste créée sans mise à jour servie. **Lire aussi l'étape « Generate latest.json » du journal du run** (même route web que ci-dessous) : `scripts/build-latest-json.sh` y nomme le paquet retenu pour chaque type (`build-latest-json: .rpm <- …`), puis le `latest.json` généré est affiché. Le script, le filtre `scripts/check-latest-json.jq` et le texte de l'étape « Publish » (avec un `curl` factice) passent par les tests de `check-frontend` à chaque PR ; l'appel du script par l'étape « Generate latest.json », le délimiteur aléatoire des notes et les vrais appels au registre ne s'exercent qu'au tag. Un manifeste refusé par le filtre arrête le job sur `ERROR: release-assets/latest.json failed scripts/check-latest-json.jq`, avant le `DELETE` : registre intact, et la release Forgejo reste créée sans mise à jour servie.
Durée observée du run : 22 à 30 min sur les 12 dernières releases — poser le `Monitor` puis passer à autre chose. Durée observée du run : 22 à 30 min sur les 12 dernières releases — poser le `Monitor` puis passer à autre chose.
**Lire aussi le Node.js du run.** `release.yml` n'est exercé qu'au tag : une montée de Node (Node 20 → 24 par #376) ne se prouve que dans ce journal. Il passe par la route web (l'API v1 des journaux répond 404). Le `run_number` vient de `/actions/tasks` : sans paramètre `page`, la route rend toute la liste, la plus récente en tête, et ignore `limit` (avec `page`, elle pagine par 30 ou par `limit`, mesuré le 2026-10-12) : **Lire aussi le Node.js du run.** `release.yml` n'est exercé qu'au tag : une montée de Node (Node 20 → 24 par #376) ne se prouve que dans ce journal. Il passe par la route web (l'API v1 des journaux répond 404). Le `run_number` vient de `/actions/tasks` : sans paramètre `page`, la route rend toute la liste, la plus récente en tête, et ignore `limit` (avec `page`, elle pagine par 30 ou par `limit`, mesuré le 2026-10-12) :
```bash ```bash
@ -63,7 +64,7 @@ updated: 2026-10-11
| grep -m1 -E 'Z v[0-9]+\.[0-9]+\.[0-9]+\s*$' | grep -m1 -E 'Z v[0-9]+\.[0-9]+\.[0-9]+\s*$'
``` ```
Attendu : `v24.x`. C'est la sortie de `node --version` dans l'étape « Install base tools and runtimes », seule ligne de ce format dans le journal (la v0.17.1, run 508, affichait `v20.20.2`). Toute autre majeure est à expliquer avant l'étape 10. Attendu : `v24.x`. C'est la sortie de `node --version` dans l'étape « Install base tools and runtimes », seule ligne de ce format dans le journal (la v0.17.1, run 508, affichait `v20.20.2`). Toute autre majeure est à expliquer avant l'étape 10.
10. **Post-CI — dérouler un vrai cycle de mise à jour** si la release touche `tauri-plugin-updater`, ses dépendances (`reqwest`/`rustls-webpki`, `tar`), `release.yml`, ou la config `updater` de `tauri.conf.json` : [`docs/qa-update-cycle.md`](../../../docs/qa-update-cycle.md). 10. **Post-CI — dérouler un vrai cycle de mise à jour** si la release touche `tauri-plugin-updater`, ses dépendances (`reqwest`/`rustls`/`rustls-webpki`), `release.yml`, ou la config `updater` de `tauri.conf.json` : [`docs/qa-update-cycle.md`](../../../docs/qa-update-cycle.md).
Inspecter `latest.json` prouve qu'il est **bien formé**, pas qu'il **installe**. Le reste de la chaîne — téléchargement TLS, vérification de signature, exécution de l'installeur, redémarrage — n'est exercé par aucun test : `cargo check`/`cargo test` prouvent que ce code compile, rien de plus. Une régression s'y voit chez l'utilisateur, au moment de la mise à jour. Inspecter `latest.json` prouve qu'il est **bien formé**, pas qu'il **installe**. Le reste de la chaîne — téléchargement TLS, vérification de signature, exécution de l'installeur, redémarrage — n'est exercé par aucun test : `cargo check`/`cargo test` prouvent que ce code compile, rien de plus. Une régression s'y voit chez l'utilisateur, au moment de la mise à jour.
@ -91,3 +92,5 @@ updated: 2026-10-11
- 2026-10-10 — Étape 9 (post-CI) : `verify-release.sh` exige les quatre clés de `latest.json` (`linux-x86_64`, `linux-x86_64-deb`, `linux-x86_64-rpm`, `windows-x86_64`), l'extension de chaque URL et le `file:` minisign de chaque signature, et sort en 1 sur tout écart (verdict en dernière ligne) ; lire l'étape « Generate latest.json » du journal. Source : #320 (milestone `planned-2026-10-10-release-deps`). - 2026-10-10 — Étape 9 (post-CI) : `verify-release.sh` exige les quatre clés de `latest.json` (`linux-x86_64`, `linux-x86_64-deb`, `linux-x86_64-rpm`, `windows-x86_64`), l'extension de chaque URL et le `file:` minisign de chaque signature, et sort en 1 sur tout écart (verdict en dernière ligne) ; lire l'étape « Generate latest.json » du journal. Source : #320 (milestone `planned-2026-10-10-release-deps`).
- 2026-10-11 — Étape 9 (post-CI) : `latest.json` à trois clés, la clé générique `linux-x86_64` étant retirée (décision de Max sur #320 : ses derniers lecteurs, AppImage v0.4.3 à v0.4.5 et binaires hors paquet, ne savaient pas installer le `.deb` qu'elle servait). Le filtre d'avant le `DELETE` vit dans `scripts/check-latest-json.jq`, que les tests de `check-frontend` exercent à chaque PR, avec la comparaison des trois listes de clés. Source : revue de la PR #416. - 2026-10-11 — Étape 9 (post-CI) : `latest.json` à trois clés, la clé générique `linux-x86_64` étant retirée (décision de Max sur #320 : ses derniers lecteurs, AppImage v0.4.3 à v0.4.5 et binaires hors paquet, ne savaient pas installer le `.deb` qu'elle servait). Le filtre d'avant le `DELETE` vit dans `scripts/check-latest-json.jq`, que les tests de `check-frontend` exercent à chaque PR, avec la comparaison des trois listes de clés. Source : revue de la PR #416.
- 2026-10-11 — Étape 2 : rejouer le garde `windows-sys` de `check-rust.yml` (ADR 0023) juste après l'édition de `Cargo.lock`, puisque le commit de release n'a pas de PR et que `check-rust` ne le verrait qu'au lundi suivant. Commande éprouvée sur le lock de la pile (exit 0) et sur une arête dérivée (exit 1). Source : revue de la PR #415. - 2026-10-11 — Étape 2 : rejouer le garde `windows-sys` de `check-rust.yml` (ADR 0023) juste après l'édition de `Cargo.lock`, puisque le commit de release n'a pas de PR et que `check-rust` ne le verrait qu'au lundi suivant. Commande éprouvée sur le lock de la pile (exit 0) et sur une arête dérivée (exit 1). Source : revue de la PR #415.
- 2026-10-16 — Étape 0 : report d'un changement de matrice des éditions vers le tableau comparatif du site (`la-compagnie-maximus/src/data/editions.ts`), PR du site préparée avant le tag et déployée après la publication. Source : la-compagnie-maximus#180.
- 2026-10-18 — Étape 9 (post-CI) : le filtre de `latest.json` est appelé avec `-s` (fichier vide ou à deux documents refusé sous jq 1.6, celle du conteneur), et le texte de l'étape « Publish » de `release.yml` est exécuté par `scripts/build-latest-json.test.mjs` ; `check-frontend` ne saute plus les PR qui ne touchent que `.claude/**` ou `release.yml`. Source : #419 (suivis de la revue de la PR #418).

View file

@ -7,6 +7,11 @@ name: PR Check — Frontend
# running it is not. A root-level config file added later would drop out of an # running it is not. A root-level config file added later would drop out of an
# allowlist without anyone noticing; here it fails safe. # allowlist without anyone noticing; here it fails safe.
# #
# .claude/** and release.yml are not ignored (#419): scripts/build-latest-json.test.mjs
# reads .claude/skills/release/scripts/verify-release.sh and runs the text of
# release.yml's "Publish latest.json to package registry" step, so a PR that
# touches only one of them must still run it.
#
# No `branches:` filter — see check-rust.yml. # No `branches:` filter — see check-rust.yml.
on: on:
@ -18,10 +23,8 @@ on:
- 'reports/**' - 'reports/**'
- 'tasks/**' - 'tasks/**'
- '.github/**' - '.github/**'
- '.claude/**'
- '.forgejo/workflows/check-rust.yml' - '.forgejo/workflows/check-rust.yml'
- '.forgejo/workflows/audit.yml' - '.forgejo/workflows/audit.yml'
- '.forgejo/workflows/release.yml'
- '*.md' - '*.md'
- 'LICENSE' - 'LICENSE'

View file

@ -177,15 +177,21 @@ jobs:
checks=$((checks + 1)) checks=$((checks + 1))
done done
done done
# Canary: a crate known to be present. If this stops being found, the # Canary: a crate known to be present on the Linux target, and only
# loop above is broken and its silence means nothing. # there. If this stops being found, the loop above is broken and its
# silence means nothing. webkit2gtk is Tauri's Linux webview (wry): a
# Tauri v2 Linux build cannot drop it, and as a Linux-only dependency
# it also proves that --target resolves target-conditional edges, which
# the per-target check above relies on. It replaced tar, a Linux-only
# dependency of the updater until #455 built it without its zip
# feature.
canary=$(cargo tree --manifest-path src-tauri/Cargo.toml --locked \ canary=$(cargo tree --manifest-path src-tauri/Cargo.toml --locked \
-i tar --target x86_64-unknown-linux-gnu 2>/dev/null) || true -i webkit2gtk --target x86_64-unknown-linux-gnu 2>/dev/null) || true
if [ -z "$canary" ]; then if [ -z "$canary" ]; then
echo "FAIL: canary 'tar' was not found although it is a known dependency. The guard is not proving anything." echo "FAIL: canary 'webkit2gtk' was not found although it is a known dependency. The guard is not proving anything."
rc=1 rc=1
else else
echo "ok: canary 'tar' found for x86_64-unknown-linux-gnu" echo "ok: canary 'webkit2gtk' found for x86_64-unknown-linux-gnu"
fi fi
echo "Suppression guard: $checks checks, exit $rc" echo "Suppression guard: $checks checks, exit $rc"
exit $rc exit $rc

View file

@ -101,9 +101,10 @@ jobs:
env: env:
CHANGELOG_NOTES: ${{ steps.changelog.outputs.notes }} CHANGELOG_NOTES: ${{ steps.changelog.outputs.notes }}
run: | run: |
# One key per Linux package, a .deb fallback and the NSIS installer; # Three keys: one per Linux package (.deb, .rpm) and the NSIS
# fails unless it finds exactly one .deb, .rpm and -setup.exe, each # installer, no generic linux-x86_64 key (#320); fails unless it finds
# with a non-empty .sig (#320). Notes reach jq as a file, as data. # exactly one .deb, .rpm and -setup.exe, each with a non-empty .sig.
# Notes reach jq as a file, as data.
# Called as scripts/build-latest-json.test.mjs calls it. # Called as scripts/build-latest-json.test.mjs calls it.
printf '%s' "$CHANGELOG_NOTES" > /tmp/release-notes.md printf '%s' "$CHANGELOG_NOTES" > /tmp/release-notes.md
bash -e -o pipefail scripts/build-latest-json.sh \ bash -e -o pipefail scripts/build-latest-json.sh \
@ -191,13 +192,16 @@ jobs:
env: env:
FORGEJO_TOKEN: ${{ secrets.PACKAGE_TOKEN }} FORGEJO_TOKEN: ${{ secrets.PACKAGE_TOKEN }}
run: | run: |
# This text is run by scripts/build-latest-json.test.mjs, with a fake
# curl: keep it self-contained, no workflow expressions (dollar,
# double brace) in run:, pass values through env: (#419).
#
# Check the manifest before the DELETE below (#320): once the old # Check the manifest before the DELETE below (#320): once the old
# version is deleted, installed apps find no update until a valid # version is deleted, installed apps find no update until a valid
# one is uploaded. A failed check stops the job, registry untouched. # one is uploaded. A failed check stops the job, registry untouched.
# The filter lives in scripts/check-latest-json.jq, which # The filter lives in scripts/check-latest-json.jq; -s makes it
# scripts/build-latest-json.test.mjs also runs on every pull # refuse an empty file or one with two documents (#419).
# request, so its key list cannot drift from the script's unseen. if ! jq -e -s --arg version "${GITHUB_REF_NAME#v}" \
if ! jq -e --arg version "${GITHUB_REF_NAME#v}" \
-f scripts/check-latest-json.jq release-assets/latest.json > /dev/null; then -f scripts/check-latest-json.jq release-assets/latest.json > /dev/null; then
echo "ERROR: release-assets/latest.json failed scripts/check-latest-json.jq, registry left as is:" echo "ERROR: release-assets/latest.json failed scripts/check-latest-json.jq, registry left as is:"
cat release-assets/latest.json cat release-assets/latest.json

View file

@ -13,6 +13,7 @@
- Une seule copie est gardée par profil dans le dossier `migration-backups` : la copie prise avant une mise à jour de la base est désormais refaite à chaque mise à jour, et une fois la mise à jour réussie, les copies plus anciennes de ce profil sont supprimées. Une copie laissée par une mise à jour interrompue n'est plus réutilisée telle quelle, puisqu'elle précédait ce que vous aviez saisi depuis. Quand une mise à jour s'arrête, le Journal nomme désormais la copie prise avant elle (#395). - Une seule copie est gardée par profil dans le dossier `migration-backups` : la copie prise avant une mise à jour de la base est désormais refaite à chaque mise à jour, et une fois la mise à jour réussie, les copies plus anciennes de ce profil sont supprimées. Une copie laissée par une mise à jour interrompue n'est plus réutilisée telle quelle, puisqu'elle précédait ce que vous aviez saisi depuis. Quand une mise à jour s'arrête, le Journal nomme désormais la copie prise avant elle (#395).
- Import : l'état vide montre la structure attendue (un dossier parent, un sous-dossier par compte), et les cartes du parcours demandent de choisir le dossier parent et de vérifier qu'une dépense compte parmi les sorties (#422). - Import : l'état vide montre la structure attendue (un dossier parent, un sous-dossier par compte), et les cartes du parcours demandent de choisir le dossier parent et de vérifier qu'une dépense compte parmi les sorties (#422).
- Parcours de démarrage : la carte Transactions explique les mots-clés et propose « Ajouter comme mot-clé » ; la carte Budget précise que seules les catégories du dernier niveau acceptent un montant et ce que fait la colonne Annuel (#430). - Parcours de démarrage : la carte Transactions explique les mots-clés et propose « Ajouter comme mot-clé » ; la carte Budget précise que seules les catégories du dernier niveau acceptent un montant et ce que fait la colonne Annuel (#430).
- La répartition d'une transaction sur plusieurs catégories (bouton Répartition de la page Transactions) fait désormais partie de l'édition Base, comme l'écran de déverrouillage le promettait déjà. En édition Gratuite, le bouton garde sa place, porte un cadenas et ouvre l'écran de déverrouillage au lieu de la fenêtre de répartition, pour toute ligne. Les répartitions déjà faites restent dans vos données : elles sont toujours comptées dans les rapports et le tableau de bord, et toujours signalées dans la liste (#369).
### Corrigé ### Corrigé
@ -27,6 +28,7 @@
- Mise à jour de dépendances npm pour corriger les huit vulnérabilités signalées par `npm audit` (cinq élevées, trois modérées), par le seul fichier de verrouillage : chaque nouvelle version reste dans la plage que `package.json` déclarait déjà. `react-router-dom` et `react-router` 7.18.1 → 7.18.4 corrigent GHSA-qwww-vcr4-c8h2 (CSRF en mode React Server Components), l'advisory laissée en l'état jusqu'ici parce qu'en sortir semblait demander une migration vers react-router v8 ; la 7.18.2 l'a corrigée. Le reste est de l'outillage de build et de test, jamais livré dans l'application : `vitest` et `@vitest/mocker` 4.1.9 → 4.1.11, `browserslist` 4.28.4 → 4.29.3, `baseline-browser-mapping` 2.10.41 → 2.11.28, `nanoid` 3.3.16 → 3.3.20 et `source-map-js` 1.2.1 → 1.2.2. Aucune de ces advisories n'était atteignable dans l'application livrée, qui exécute react-router dans sa propre fenêtre, sans serveur. De toutes ces mises à jour, seule celle de react-router modifie l'application construite : elle refuse désormais une navigation vers une autre origine, ce que l'application ne fait jamais, donc la navigation se comporte comme avant (#358). - Mise à jour de dépendances npm pour corriger les huit vulnérabilités signalées par `npm audit` (cinq élevées, trois modérées), par le seul fichier de verrouillage : chaque nouvelle version reste dans la plage que `package.json` déclarait déjà. `react-router-dom` et `react-router` 7.18.1 → 7.18.4 corrigent GHSA-qwww-vcr4-c8h2 (CSRF en mode React Server Components), l'advisory laissée en l'état jusqu'ici parce qu'en sortir semblait demander une migration vers react-router v8 ; la 7.18.2 l'a corrigée. Le reste est de l'outillage de build et de test, jamais livré dans l'application : `vitest` et `@vitest/mocker` 4.1.9 → 4.1.11, `browserslist` 4.28.4 → 4.29.3, `baseline-browser-mapping` 2.10.41 → 2.11.28, `nanoid` 3.3.16 → 3.3.20 et `source-map-js` 1.2.1 → 1.2.2. Aucune de ces advisories n'était atteignable dans l'application livrée, qui exécute react-router dans sa propre fenêtre, sans serveur. De toutes ces mises à jour, seule celle de react-router modifie l'application construite : elle refuse désormais une navigation vers une autre origine, ce que l'application ne fait jamais, donc la navigation se comporte comme avant (#358).
- Mise à jour de la dépendance Rust `rustls` (0.23.45 → 0.23.46), corrigeant RUSTSEC-2026-0346, RUSTSEC-2026-0347, RUSTSEC-2026-0348 et RUSTSEC-2026-0349. `rustls` est la couche TLS de la vérification et du téléchargement des mises à jour automatiques, atteinte via `tauri-plugin-updater` ; les appels de licence, d'authentification, de cours et de rétroaction de l'application passent par un autre moteur TLS. Aucune des quatre ne s'applique à l'usage qu'en fait l'application : aucun magasin de sessions maison ne peut journaliser de secret de session (0346), l'Encrypted Client Hello n'est jamais activé (0347), la décompression Brotli des certificats, optionnelle, n'est pas compilée (0348), et le fournisseur cryptographique `ring` ne propose aucun échange de clés post-quantique qui pourrait être rabattu (0349). Mise à jour quand même, plutôt que contournée par un raisonnement, puisqu'un correctif existe. Rien ne change dans le code de l'application elle-même — seul le fichier de verrouillage bouge, et `Cargo.toml` est intouché. Ce qui change, c'est le comportement de la couche TLS elle-même : outre ces quatre correctifs, la 0.23.46 apporte une dizaine d'autres corrections (conformité au protocole, durcissement, entrées-sorties), comme le refus des enregistrements `change_cipher_spec` protégés et la mise à jour des clés TLS 1.3 au moment voulu (#447). - Mise à jour de la dépendance Rust `rustls` (0.23.45 → 0.23.46), corrigeant RUSTSEC-2026-0346, RUSTSEC-2026-0347, RUSTSEC-2026-0348 et RUSTSEC-2026-0349. `rustls` est la couche TLS de la vérification et du téléchargement des mises à jour automatiques, atteinte via `tauri-plugin-updater` ; les appels de licence, d'authentification, de cours et de rétroaction de l'application passent par un autre moteur TLS. Aucune des quatre ne s'applique à l'usage qu'en fait l'application : aucun magasin de sessions maison ne peut journaliser de secret de session (0346), l'Encrypted Client Hello n'est jamais activé (0347), la décompression Brotli des certificats, optionnelle, n'est pas compilée (0348), et le fournisseur cryptographique `ring` ne propose aucun échange de clés post-quantique qui pourrait être rabattu (0349). Mise à jour quand même, plutôt que contournée par un raisonnement, puisqu'un correctif existe. Rien ne change dans le code de l'application elle-même — seul le fichier de verrouillage bouge, et `Cargo.toml` est intouché. Ce qui change, c'est le comportement de la couche TLS elle-même : outre ces quatre correctifs, la 0.23.46 apporte une dizaine d'autres corrections (conformité au protocole, durcissement, entrées-sorties), comme le refus des enregistrements `change_cipher_spec` protégés et la mise à jour des clés TLS 1.3 au moment voulu (#447).
- Retrait de l'extraction d'archive de la mise à jour automatique (sa dépendance `zip` sous Windows et `tar` sous Linux), corrigeant RUSTSEC-2026-0367, RUSTSEC-2026-0368 et RUSTSEC-2026-0369. La mise à jour n'extrayait un paquet que s'il arrivait sous forme d'archive (un zip sous Windows, une AppImage en `.tar.gz` sous Linux), après vérification de sa signature, et les mises à jour de cette application ne sont jamais des archives : l'installeur Windows (`.exe`) est lancé tel quel et les paquets Linux (`.deb`, `.rpm`) sont installés par le gestionnaire de paquets du système. Aucun des trois avis, dont deux portent sur des permissions Unix alors que `zip` n'était compilé que pour Windows, n'était atteignable ; la dépendance est retirée plutôt que contournée par un raisonnement, faute de version corrigée compatible avec la mise à jour. Les mises à jour de l'installeur Windows et des paquets Linux fonctionnent comme avant (#455).
## [0.17.1] - 2026-10-08 ## [0.17.1] - 2026-10-08

View file

@ -13,6 +13,7 @@
- One copy is kept per profile in the `migration-backups` folder: the copy taken before a database update is now taken again at each update, and once the update succeeds, the older copies of that profile are deleted. A copy left by an update that stopped is no longer reused as is, since it predated what you entered after it. When an update stops, the Journal now names the copy taken before it (#395). - One copy is kept per profile in the `migration-backups` folder: the copy taken before a database update is now taken again at each update, and once the update succeeds, the older copies of that profile are deleted. A copy left by an update that stopped is no longer reused as is, since it predated what you entered after it. When an update stops, the Journal now names the copy taken before it (#395).
- Import: the empty state shows the expected structure (a parent folder, one subfolder per account), and the getting-started cards ask you to choose the parent folder and to check that an expense counts among the outflows (#422). - Import: the empty state shows the expected structure (a parent folder, one subfolder per account), and the getting-started cards ask you to choose the parent folder and to check that an expense counts among the outflows (#422).
- Getting started: the Transactions card explains keywords and suggests “Add as keyword”; the Budget card states that only last-level categories take an amount and what the Annual column does (#430). - Getting started: the Transactions card explains keywords and suggests “Add as keyword”; the Budget card states that only last-level categories take an amount and what the Annual column does (#430).
- Splitting a transaction across categories (the Split button of the Transactions page) is now part of the Base edition, as the unlock screen already promised. On the Free edition the button keeps its place, shows a lock and opens the unlock screen instead of the split window, for every row. Splits made earlier stay in your data: they are still counted in the reports and the dashboard, and still flagged in the list (#369).
### Fixed ### Fixed
@ -27,6 +28,7 @@
- Updated npm dependencies to clear the eight vulnerabilities reported by `npm audit` (five high, three moderate), through the lockfile alone: every new version stays within the range `package.json` already declares. `react-router-dom` and `react-router` 7.18.1 → 7.18.4 clear GHSA-qwww-vcr4-c8h2 (CSRF in React Server Components mode), the advisory left as is until now because leaving its range seemed to require migrating to react-router v8; 7.18.2 fixed it. The rest is build and test tooling, never shipped in the app: `vitest` and `@vitest/mocker` 4.1.9 → 4.1.11, `browserslist` 4.28.4 → 4.29.3, `baseline-browser-mapping` 2.10.41 → 2.11.28, `nanoid` 3.3.16 → 3.3.20 and `source-map-js` 1.2.1 → 1.2.2. None of these advisories was reachable in the shipped app, which runs react-router in its own window, with no server. Of all these updates, only react-router's changes the built app: it now refuses a navigation to another origin, which the app never makes, so navigation behaves as before (#358). - Updated npm dependencies to clear the eight vulnerabilities reported by `npm audit` (five high, three moderate), through the lockfile alone: every new version stays within the range `package.json` already declares. `react-router-dom` and `react-router` 7.18.1 → 7.18.4 clear GHSA-qwww-vcr4-c8h2 (CSRF in React Server Components mode), the advisory left as is until now because leaving its range seemed to require migrating to react-router v8; 7.18.2 fixed it. The rest is build and test tooling, never shipped in the app: `vitest` and `@vitest/mocker` 4.1.9 → 4.1.11, `browserslist` 4.28.4 → 4.29.3, `baseline-browser-mapping` 2.10.41 → 2.11.28, `nanoid` 3.3.16 → 3.3.20 and `source-map-js` 1.2.1 → 1.2.2. None of these advisories was reachable in the shipped app, which runs react-router in its own window, with no server. Of all these updates, only react-router's changes the built app: it now refuses a navigation to another origin, which the app never makes, so navigation behaves as before (#358).
- Updated the Rust dependency `rustls` (0.23.45 → 0.23.46), clearing RUSTSEC-2026-0346, RUSTSEC-2026-0347, RUSTSEC-2026-0348 and RUSTSEC-2026-0349. `rustls` is the TLS layer of the automatic update check and download, reached through `tauri-plugin-updater`; the application's own licence, authentication, market-price and feedback calls use a different TLS backend. None of the four applies to how the application uses it: no custom session store exists that could log session secrets (0346), Encrypted Client Hello is never enabled (0347), the optional Brotli certificate decompression is not compiled in (0348), and the `ring` cryptography provider offers no post-quantum key exchange that could be downgraded (0349). Updated anyway rather than reasoned around, since a fix exists. Nothing in the application's own code changes — only the lockfile moves, and `Cargo.toml` is untouched. What changes is the TLS layer's own behaviour: besides these four fixes, 0.23.46 carries about ten other corrections (protocol conformance, hardening, I/O), such as rejecting protected `change_cipher_spec` records and performing TLS 1.3 key updates on time (#447). - Updated the Rust dependency `rustls` (0.23.45 → 0.23.46), clearing RUSTSEC-2026-0346, RUSTSEC-2026-0347, RUSTSEC-2026-0348 and RUSTSEC-2026-0349. `rustls` is the TLS layer of the automatic update check and download, reached through `tauri-plugin-updater`; the application's own licence, authentication, market-price and feedback calls use a different TLS backend. None of the four applies to how the application uses it: no custom session store exists that could log session secrets (0346), Encrypted Client Hello is never enabled (0347), the optional Brotli certificate decompression is not compiled in (0348), and the `ring` cryptography provider offers no post-quantum key exchange that could be downgraded (0349). Updated anyway rather than reasoned around, since a fix exists. Nothing in the application's own code changes — only the lockfile moves, and `Cargo.toml` is untouched. What changes is the TLS layer's own behaviour: besides these four fixes, 0.23.46 carries about ten other corrections (protocol conformance, hardening, I/O), such as rejecting protected `change_cipher_spec` records and performing TLS 1.3 key updates on time (#447).
- Removed the archive extraction of the automatic updater (its `zip` dependency on Windows and `tar` on Linux), clearing RUSTSEC-2026-0367, RUSTSEC-2026-0368 and RUSTSEC-2026-0369. The updater only extracted an update that arrived as an archive (a zip on Windows, a `.tar.gz` AppImage on Linux), after checking its signature, and this application's updates are never archives: the Windows installer (`.exe`) is run as it is and the Linux packages (`.deb`, `.rpm`) are installed by the system's package manager. None of the three advisories, two of which concern Unix permissions while `zip` was only compiled for Windows, was reachable; the dependency is removed rather than reasoned around, since no fixed version fits the updater. Updates of the Windows installer and of the Linux packages work as before (#455).
## [0.17.1] - 2026-10-08 ## [0.17.1] - 2026-10-08

View file

@ -134,6 +134,8 @@ La documentation technique est centralisée dans `docs/` :
- `docs/guide-utilisateur.md` — Guide utilisateur - `docs/guide-utilisateur.md` — Guide utilisateur
- `docs/archive/` — Anciennes spécifications archivées - `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 :** **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` - 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/` - Décision technique structurante (choix de librairie, pattern architectural, changement de stratégie) → créer un nouvel ADR dans `docs/adr/`
@ -163,7 +165,7 @@ Pour maintenir l'éligibilité aux crédits d'impôt R&D (RS&DE fédéral + CRIC
Workflows Forgejo Actions dans `.forgejo/workflows/`. Le runner est à **capacité 1** — les jobs se suivent, ils ne tournent pas en parallèle. Workflows Forgejo Actions dans `.forgejo/workflows/`. Le runner est à **capacité 1** — les jobs se suivent, ils ne tournent pas en parallèle.
- **`check-rust.yml`** — déclenché sur les PR touchant `src-tauri/**` ou `.cargo/**`, plus chaque lundi à 06:30 UTC sur `main` et par `workflow_dispatch`. Lance `cargo check`, une vérification bloquante que les advisories acceptées restent non atteignables, une autre que les sept arêtes `windows-sys` à borne large du lock restent sur `0.61.2` ([ADR 0023](docs/adr/0023-windows-sys-pinning.md)), `cargo test` et un `cargo audit` informatif, avec le cache `Swatinem/rust-cache`. Doit être vert avant tout merge. Un `cargo update` qui re-résout ces arêtes (`-p hyper` et `-p h2` l'ont fait) fait rougir le garde `windows-sys` : remettre les arêtes à la main selon l'ADR, ne jamais modifier la liste gardée pour le faire passer. - **`check-rust.yml`** — déclenché sur les PR touchant `src-tauri/**` ou `.cargo/**`, plus chaque lundi à 06:30 UTC sur `main` et par `workflow_dispatch`. Lance `cargo check`, une vérification bloquante que les advisories acceptées restent non atteignables, une autre que les sept arêtes `windows-sys` à borne large du lock restent sur `0.61.2` ([ADR 0023](docs/adr/0023-windows-sys-pinning.md)), `cargo test` et un `cargo audit` informatif, avec le cache `Swatinem/rust-cache`. Doit être vert avant tout merge. Un `cargo update` qui re-résout ces arêtes (`-p hyper` et `-p h2` l'ont fait) fait rougir le garde `windows-sys` : remettre les arêtes à la main selon l'ADR, ne jamais modifier la liste gardée pour le faire passer.
- **`check-frontend.yml`** — déclenché sur les PR, sauf si tous les fichiers modifiés sont du Rust, de la doc ou du markdown. Lance `npm run build` (tsc + vite) et `npm test` (vitest). Doit être vert avant tout merge. **Aucune étape `npm audit`** : les advisories npm ne sont pas un gate de CI. `npm audit` à 0 au 2026-10-10 ([#358](https://git.lacompagniemaximus.com/maximus/simpl-resultat/issues/358)), un constat daté : aucun job ne le fait échouer, mais `npm ci` en imprime le compte à chaque run de `check-frontend`, et le Défenseur relève les advisories de son côté (#358 en vient). - **`check-frontend.yml`** — déclenché sur les PR, sauf si tous les fichiers modifiés sont du Rust, de la doc, du markdown, `check-rust.yml` ou `audit.yml` ; `.claude/**` et `release.yml` le déclenchent, car `scripts/build-latest-json.test.mjs` lit `verify-release.sh` et exécute l'étape « Publish » de `release.yml` (#419). Lance `npm run build` (tsc + vite) et `npm test` (vitest). Doit être vert avant tout merge. **Aucune étape `npm audit`** : les advisories npm ne sont pas un gate de CI. `npm audit` à 0 au 2026-10-10 ([#358](https://git.lacompagniemaximus.com/maximus/simpl-resultat/issues/358)), un constat daté : aucun job ne le fait échouer, mais `npm ci` en imprime le compte à chaque run de `check-frontend`, et le Défenseur relève les advisories de son côté (#358 en vient).
- **`audit.yml`** — audit RustSec quotidien (06:00 UTC) + `workflow_dispatch`, pour couvrir les avis de sécurité entre deux PR Rust. Échec bloquant. **Un run vert signifie « zéro advisory hors de la liste acceptée »**, pas « zéro advisory » : cette liste est dans `.cargo/audit.toml` (preuve de non-atteignabilité et condition de retrait par entrée), encadrée par l'[ADR 0018](docs/adr/0018-suppression-advisories-non-atteignables.md). Ne jamais y ajouter une advisory atteignable ni élargir une entrée à un crate entier. - **`audit.yml`** — audit RustSec quotidien (06:00 UTC) + `workflow_dispatch`, pour couvrir les avis de sécurité entre deux PR Rust. Échec bloquant. **Un run vert signifie « zéro advisory hors de la liste acceptée »**, pas « zéro advisory » : cette liste est dans `.cargo/audit.toml` (preuve de non-atteignabilité et condition de retrait par entrée), encadrée par l'[ADR 0018](docs/adr/0018-suppression-advisories-non-atteignables.md). Ne jamais y ajouter une advisory atteignable ni élargir une entrée à un crate entier.
- **`release.yml`** — déclenché par les tags `v*`. Build Windows (NSIS `.exe`) + Linux (`.deb`, `.rpm`), signe les binaires et publie le JSON d'updater pour les mises à jour automatiques. - **`release.yml`** — déclenché par les tags `v*`. Build Windows (NSIS `.exe`) + Linux (`.deb`, `.rpm`), signe les binaires et publie le JSON d'updater pour les mises à jour automatiques.

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` n'a aucune CI : les `paths` de `check-rust.yml` ne le listent pas, et le `paths-ignore` de `check-frontend.yml` l'exclut. Une PR qui ne touche que `release.yml` lance `check-frontend`, dont un test exécute le texte de l'étape « Publish latest.json to package registry » avec un `curl` factice (#419) ; le reste de `release.yml` ne s'exerce qu'au tag. 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-18. 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/**`, `*.md` à la racine, `LICENSE`, `check-rust.yml` et `audit.yml` | `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 # STATE — Simpl'Résultat
> Derniere 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).) > Derniere MAJ : 2026-10-18 (**#419 mergée et fermée — `latest.json` vide ou à plusieurs documents refusé avant le `DELETE` du registre, étape « Publish » exécutée par les tests, PR #464 en avance rapide par l'API, `main` `c859405`.** Le blocker « avant le prochain tag : #419, point 1 » est levé ; `check-frontend` ne saute plus `.claude/**` ni `release.yml` (décision de Max), déclencheur pas encore prouvé par une PR qui ne toucherait que ces chemins. Deuxième PR mesurée du pilote #458. Aucune migration (v1→v17).)
> >
> Précédente MAJ : 2026-10-15 (**Milestone `planned-2026-10-12-retours-onboarding-v0171` mergée, 8/8 fermée — pile #438 → #445 en avance rapide par l'API, `main` `934bcbf`.** Grille Budget en profondeur (#424), import par sous-dossier de compte, fichiers à la racine signalés, nom de source = sous-dossier (#422, #427, #426), cartes Transactions/Budget (#430), aide périmée (#431), étapes de visite et « J'ai compris » (#425), cartes Catégories et Rapports (#432). Suivi #446. Aucune migration (v1→v17).) > Précédente MAJ : 2026-10-18 (**#455 mergée et fermée — `tauri-plugin-updater` sans sa feature `zip`, PR #459 en avance rapide par l'API, `main` `c5efc38` ; `audit.yml` de nouveau vert sur `main` (run 564).** #457 fermée (skills de revue branchés sur `REVIEW.md`), pilote #458 lancé avec #459 comme première PR mesurée. Suivi #463. Aucune migration (v1→v17).)
## Position actuelle ## Position actuelle
@ -40,8 +40,21 @@ Audit critique de la page Bilan livré (`docs/audit-bilan-2026-05.md`, revue CPA
**#447 mergée sur `main`** (2026-10-16, `1763a9f`, pas encore publiée) — `rustls` 0.23.45 → 0.23.46 (PR #448 : deux lignes de `Cargo.lock`, aucun bump compagnon, aucune arête `windows-sys` déplacée) corrige RUSTSEC-2026-0346 à -0349, publiées le 2026-10-14, qui rougissaient `audit.yml` depuis le 2026-10-15 sans que le lock ait bougé depuis la v0.17.1. Aucune des quatre n'est atteignable (`rustls` n'est atteint que par `tauri-plugin-updater` → `reqwest` 0.13), mais la règle 1 de `.cargo/audit.toml` demande de corriger plutôt que de supprimer ; `audit.yml` est de nouveau vert sur `main` (run 556). Pas de release corrective : le bump part avec le prochain tag, qui devra dérouler le cycle réel de mise à jour. `[Unreleased]` porte un second Sécurité. Suivis : #449 et agent-defenseurs#186. Aucune migration (v1→v17). **#447 mergée sur `main`** (2026-10-16, `1763a9f`, pas encore publiée) — `rustls` 0.23.45 → 0.23.46 (PR #448 : deux lignes de `Cargo.lock`, aucun bump compagnon, aucune arête `windows-sys` déplacée) corrige RUSTSEC-2026-0346 à -0349, publiées le 2026-10-14, qui rougissaient `audit.yml` depuis le 2026-10-15 sans que le lock ait bougé depuis la v0.17.1. Aucune des quatre n'est atteignable (`rustls` n'est atteint que par `tauri-plugin-updater` → `reqwest` 0.13), mais la règle 1 de `.cargo/audit.toml` demande de corriger plutôt que de supprimer ; `audit.yml` est de nouveau vert sur `main` (run 556). Pas de release corrective : le bump part avec le prochain tag, qui devra dérouler le cycle réel de mise à jour. `[Unreleased]` porte un second Sécurité. Suivis : #449 et agent-defenseurs#186. Aucune migration (v1→v17).
**#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. Depuis #457 (fermée le 2026-10-18), `/pr-review` le lit sur `main` et annonce le niveau en tête de chaque verdict ; le pilote #458 mesure les 20 PR suivantes, la première étant #459. Suivis : #461 et #462. Aucune migration (v1→v17).
**#455 mergée sur `main`** (2026-10-18, `c5efc38`, pas encore publiée) — `tauri-plugin-updater` compilé sans sa feature `zip` (`default-features = false`, `rustls-tls` gardé) : `zip`, `arbitrary` et `derive_arbitrary` sortent du lock (666 → 663 paquets, aucun ajouté), et avec eux RUSTSEC-2026-0367 à -0369, retirés et non supprimés dans `.cargo/audit.toml` ; `tar` quitte le graphe Linux, d'où le canari de la garde des suppressions de `check-rust` passé de `tar` à `webkit2gtk` (ADR 0018 amendé ; `architecture.md`, `docs/qa-update-cycle.md` et skill `/release` à jour). `[Unreleased]` porte un troisième Sécurité. `audit.yml` vert sur `main` (run 564). Suivi : #463. Aucune migration (v1→v17).
**#419 mergée sur `main`** (2026-10-18, `c859405`, pas encore publiée) — `scripts/check-latest-json.jq` exige exactement un document (`length == 1 and (.[0] | …)`) et l'étape « Publish » de `release.yml` l'appelle avec `-s` : sous jq 1.6, la version du conteneur de release, un `latest.json` vide passait (rc 0) et un fichier à deux documents était jugé sur le dernier. `scripts/build-latest-json.test.mjs` (28 → 36 tests) exécute le texte exact de l'étape « Publish » avec un `curl` factice : `DELETE` puis `PUT` sur un manifeste valide, sortie 1 sans aucune requête sur un refus. `check-frontend` ne saute plus `.claude/**` ni `release.yml` (`REVIEW.md`, `CLAUDE.md` et `architecture.md` à jour) ; commentaire périmé de `release.yml:104` corrigé (point 3 de #461) ; `docs/qa-update-cycle.md` note qu'un build `tauri dev` sous Linux affiche `TargetsNotFound`, attendu depuis #418. Aucune entrée de CHANGELOG (rien de visible pour l'utilisateur). Aucune migration (v1→v17).
## Decisions recentes ## Decisions recentes
- 2026-10-18 : **#419 mergée — `/fix-issue` (contrôle du plan refusé deux fois, chaque fois sur un point réel) → `/pr-review` APPROVE de niveau haut → merge ff par l'API (PR #464, `main` `c859405`) : un `latest.json` vide ou à plusieurs documents ne passe plus la garde d'avant le `DELETE` du registre, et l'étape « Publish » est testée telle quelle.** Le filtre `scripts/check-latest-json.jq` exige exactement un document, appelé avec `-s` par `release.yml` ; défaut reproduit avant le correctif avec le binaire statique officiel de jq 1.6 (celui d'`ubuntu:22.04`) : fichier vide accepté en rc 0, refusé en rc 4 par jq 1.7. `build-latest-json.test.mjs` (28 → 36 tests) extrait le `run:` de l'étape « Publish », identique à l'octet à une lecture PyYAML (46 lignes), et le lance sous `bash -e -o pipefail` avec un `curl` factice en tête du `PATH` ; une garde vérifie que ce texte ne contient aucune expression de workflow, que le runner substituerait même dans un commentaire sans que le test le voie. Refus du plan check : `REVIEW.md` non mis à jour alors que le déclencheur de `check-frontend` changeait, puis une expression de workflow littérale dans un commentaire du `run:`, que le runner aurait évaluée ; troisième passage : pass. **Décision de Max** : `check-frontend` ne saute plus `.claude/**` ni `release.yml`, puisque le test lit `verify-release.sh` et exécute l'étape « Publish » (environ 2,5 min de runner de plus sur ces PR) ; `REVIEW.md`, `CLAUDE.md` et `architecture.md` suivent. Aussi : commentaire périmé de `release.yml:104` (point 3 de #461, commentaire 24939, points 1 et 2 toujours ouverts), ligne `TargetsNotFound` de `tauri dev` sous Linux dans `docs/qa-update-cycle.md`, skill `/release` à jour. Hors dépôt : `la-compagnie-maximus/docs/forgejo-ops.md`, commit `9a8e3f5` (`/actions/tasks` ne lit `limit` qu'avec `page`, remesuré le 2026-10-18 sur 654 tâches ; cas du run 513, que `cancel-in-progress` n'a pas annulé). Preuves : cinq mutations de l'auteur, toutes rouges sous jq 1.6 et 1.7 ; revue de niveau haut (commentaire 24912), six mutations du relecteur toutes rouges, passe sécurité sans constat ; `check-frontend` run 565 vert sous jq 1.6 (134 fichiers, 2256 tests) ; #419 fermée par le merge (commentaire 24941). Deuxième PR mesurée du pilote #458. **Reste à prouver** (suggestion 1 de la revue) : aucune PR ne touchant que `.claude/**` ou `release.yml` n'a encore montré que `check-frontend` se déclenche, la PR #464 touchant aussi `scripts/` ; la première de ce type en fera la preuve. Reste avant le tag de la v0.17.2 : #421. Aucune migration (v1→v17). (ref #419, PR #464, #461, #458)
- 2026-10-18 : **#455 mergée — `/fix-issue` → `/pr-review` de niveau haut REQUEST_CHANGES, corrigé dans le texte → merge ff par l'API sans second tour (PR #459, `main` `c5efc38`) : `tauri-plugin-updater` compilé sans sa feature `zip`, `audit.yml` de nouveau vert. #457 fermée : les skills de revue lisent `REVIEW.md`.** `zip` 4.6.1, compilé pour Windows seulement par la feature par défaut `zip` de l'updater, n'est corrigé qu'en 9.0.0, hors de portée de `tauri-plugin-updater` 2.14.0 (`zip ^4`) : l'updater déclaré en `default-features = false` avec `rustls-tls` sort `zip`, `arbitrary` et `derive_arbitrary` du lock (666 → 663) et avec eux RUSTSEC-2026-0367 à -0369, sans entrée dans `.cargo/audit.toml` (règle 1 de l'ADR 0018). `tar` quittant le graphe Linux, le canari de la garde des suppressions passe à `webkit2gtk`, conditionnel à Linux comme l'était `tar` (le contrôle du plan a écarté `tauri`, inconditionnel, que proposait l'analyse) ; preuve Windows par `cargo xwin check`, témoin sur `main` (`zip` compilé) puis branche (sans `zip`). Première PR mesurée du pilote #458, de niveau haut (fixé par `src-tauri/Cargo.toml`, `src-tauri/Cargo.lock`, l'étape de verdict de `check-rust.yml` et le skill `/release`) : la revue (commentaire 24757) rejoue le correctif (quatre mutations du canari, `cargo xwin check`, passe sécurité sans constat de §1 à §4) et bloque sur une fausse preuve du corps de la PR : le cycle de mise à jour de la prochaine release part de la v0.17.1, dont l'updater a encore `zip`, il n'exécute donc pas le chemin d'installation sans `zip`. Corrigé sans commit dans le corps de la PR et dans celui de #455 (commentaire 24806) ; **décision de Max** : merger sans second tour. Preuves : rebase sur `main`, les neuf fichiers de la PR identiques à l'octet à la tête relue `85b7ef9` ; `check-rust` run 563 vert sur `c5efc38` (canari `webkit2gtk`, gardes 2/2 et 9/9, 267 tests, audit à 663 crates, lock non réécrit) ; après le merge, `workflow_dispatch` d'`audit.yml` : run 564 vert (cargo-audit 0.22.2, 1338 avis, 663 crates, aucune vulnérabilité, aucun des trois IDs `zip` ; 18 avertissements autorisés, les 12 d'avant et six `unmaintained` parus le 2026-10-18, RUSTSEC-2026-0370 à -0372) ; PR en `Refs #455`, d'où une fermeture à la main (commentaire 24830). #457 (skills globaux, hors git, aucune PR) : `pr-review` lit `REVIEW.md` sur la branche par défaut (Context), calcule le niveau au Step 0bis et l'annonce en première ligne du verdict ; profondeur par niveau (bas = lecture du diff ; haut = mutation obligatoire, passe sécurité bloquante faite dans le fork, car un fork ne lance pas d'agent et `security-scan` n'est défini qu'au niveau du monorepo, APPROVE avec « Max doit lire avant le merge. ») ; savoir propre à simpl-resultat retiré du skill ; `sprint` 4b renvoie à `/pr-review` au lieu d'une copie de checklist (la copie avait perdu la section Tests) ; `DAILY-REPORT` d'`autopilot` avec une section « Max doit lire » et `prs_high_level` ; `fix-issue` 6.5 prend ses commandes dans `REVIEW.md` ; une entrée de CHANGELOG par skill. Preuve : la revue de la PR #459 ouvre sur la ligne de niveau (commentaire 24757) ; fermeture au commentaire 24764. Pilote #458 en `status:in-progress`, relevé ouvert (commentaire 24819), PR 1 = #459. **Acquis** : le cycle réel de mise à jour d'une release exécute l'updater de la version précédente ; seul le `check()` final passe par le nouveau. Suivi #463 (l'étape 10 de `/release` et `docs/qa-update-cycle.md` rattachent le cycle à la release qui livre le changement, alors qu'un changement de l'updater ne se prouve qu'au cycle N → N+1). Aucune migration (v1→v17). (ref #455, PR #459, #457, #458, #463)
- 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-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) - 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)
- 2026-10-13 : **Milestone `planned-2026-10-10-fiabilite-tests` mergée — `/autopilot` (4 workers en série, 1 h 25, CI lue à chaque maillon) → quatre `/pr-review` APPROVE en parallèle → suivis en tête de pile → merge ff par l'API, chaque enfant reciblé sur `main` (`main` `c17bf5d`, 4 PRs `merged`).** Mesure du rattrapage : 306 ms en release pour 100 001 transactions (T490, NVMe), sous la règle des 3 s, donc `STARTUP_TIMEOUT_MS` reste à 10 000 (ADR 0021). Inventaire de #389 : 27 fichiers et non 24, trois mocks bruts venus de #423. Suivis appliqués en tête (`74fae14`, `a1c31a1`, `ac3a981`, `86eff9b`, `c17bf5d`), non relus : `close()` d'un ancien proxy rejeté, échec de fermeture consigné sans bloquer le changement de profil, `withoutFolders` qui ne laisse plus passer un dossier contenant une espace, garde i18n plus stricte, badge traduit. Tip éprouvé en worktree `npm ci` propre : sonde rc=1, tsc, build, 2065 vitest, 262 Rust. ADR 0024 (serveur MCP, Proposed) rebasé sur le merge puis poussé (`977ef3a`). **Acquis** : l'orchestrateur a d'abord contredit à tort un constat juste du worker (badge `best-effort` codé en dur), sur un `grep` tronqué par `head` ; ne jamais conclure à l'absence depuis une sortie tronquée. Aucune migration (v1→v17). (ref #408, #409, #389, #395, PRs #433-#436, #437) - 2026-10-13 : **Milestone `planned-2026-10-10-fiabilite-tests` mergée — `/autopilot` (4 workers en série, 1 h 25, CI lue à chaque maillon) → quatre `/pr-review` APPROVE en parallèle → suivis en tête de pile → merge ff par l'API, chaque enfant reciblé sur `main` (`main` `c17bf5d`, 4 PRs `merged`).** Mesure du rattrapage : 306 ms en release pour 100 001 transactions (T490, NVMe), sous la règle des 3 s, donc `STARTUP_TIMEOUT_MS` reste à 10 000 (ADR 0021). Inventaire de #389 : 27 fichiers et non 24, trois mocks bruts venus de #423. Suivis appliqués en tête (`74fae14`, `a1c31a1`, `ac3a981`, `86eff9b`, `c17bf5d`), non relus : `close()` d'un ancien proxy rejeté, échec de fermeture consigné sans bloquer le changement de profil, `withoutFolders` qui ne laisse plus passer un dossier contenant une espace, garde i18n plus stricte, badge traduit. Tip éprouvé en worktree `npm ci` propre : sonde rc=1, tsc, build, 2065 vitest, 262 Rust. ADR 0024 (serveur MCP, Proposed) rebasé sur le merge puis poussé (`977ef3a`). **Acquis** : l'orchestrateur a d'abord contredit à tort un constat juste du worker (badge `best-effort` codé en dur), sur un `grep` tronqué par `head` ; ne jamais conclure à l'absence depuis une sortie tronquée. Aucune migration (v1→v17). (ref #408, #409, #389, #395, PRs #433-#436, #437)
@ -53,49 +66,19 @@ Audit critique de la page Bilan livré (`docs/audit-bilan-2026-05.md`, revue CPA
- **Restes** : #419, dont le point 1 (sous jq 1.6, le filtre accepte un `latest.json` vide ou à deux documents, inatteignable aujourd'hui) à corriger avant le prochain tag ; vérifications de la prochaine release dans Blockers ; vérification de la navigation dans l'app lancée (Linux, Windows), confirmatoire. - **Restes** : #419, dont le point 1 (sous jq 1.6, le filtre accepte un `latest.json` vide ou à deux documents, inatteignable aujourd'hui) à corriger avant le prochain tag ; vérifications de la prochaine release dans Blockers ; vérification de la navigation dans l'app lancée (Linux, Windows), confirmatoire.
- Aucune migration DB (v1→v17). (ref #376, #345, #320, #358, #317, PRs #414-#418, #419, ADR 0023) - Aucune migration DB (v1→v17). (ref #376, #345, #320, #358, #317, PRs #414-#418, #419, ADR 0023)
- 2026-10-08 : **v0.17.1 taggée et publiée (`e4db715`, run 508 vert), sur demande de Max (« /deploy et nouveau release 17.1 »).** Pré-vol du skill sur le tip `6de73b3` : build, 1904 vitest, `cargo check` sans avertissement, 255 Rust ; `.claude/worktrees/` vide. Diff depuis `v0.17.0` : `Cargo.lock`, `release.yml` et `tauri.conf.json` inchangés, seul ajout au manifeste Rust une dépendance de test (`tauri` avec la feature `test`, #390), rien sur 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. Numéro : `[Unreleased]` portait 6 entrées Added, et la convention (v0.16.0, v0.17.0) aurait donné une mineure ; question posée, Max a gardé 0.17.1. 5 manifestes, 2 changelogs et la ligne de `CLAUDE.md`, `Cargo.lock` édité à la main et accepté par `cargo metadata --locked`. Tag annoté poussé seul (jamais `--tags`). Run 508 ; `verify-release.sh` vert (7 artefacts, signatures et URL en 0.17.1) ; l'endpoint de `tauri.conf.json` (registre de paquets) sert la 0.17.1. Les vérifications manuelles (parcours guidé, post-merge de #390) passent après la publication, comme pour la v0.16.0. Aucune migration DB (v1→v17). (ref v0.17.1, #396-#401, #390) - 2026-10-08 : **v0.17.1 taggée et publiée (`e4db715`, run 508 vert), sur demande de Max (« /deploy et nouveau release 17.1 »).** Pré-vol du skill sur le tip `6de73b3` : build, 1904 vitest, `cargo check` sans avertissement, 255 Rust ; `.claude/worktrees/` vide. Diff depuis `v0.17.0` : `Cargo.lock`, `release.yml` et `tauri.conf.json` inchangés, seul ajout au manifeste Rust une dépendance de test (`tauri` avec la feature `test`, #390), rien sur 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. Numéro : `[Unreleased]` portait 6 entrées Added, et la convention (v0.16.0, v0.17.0) aurait donné une mineure ; question posée, Max a gardé 0.17.1. 5 manifestes, 2 changelogs et la ligne de `CLAUDE.md`, `Cargo.lock` édité à la main et accepté par `cargo metadata --locked`. Tag annoté poussé seul (jamais `--tags`). Run 508 ; `verify-release.sh` vert (7 artefacts, signatures et URL en 0.17.1) ; l'endpoint de `tauri.conf.json` (registre de paquets) sert la 0.17.1. Les vérifications manuelles (parcours guidé, post-merge de #390) passent après la publication, comme pour la v0.16.0. Aucune migration DB (v1→v17). (ref v0.17.1, #396-#401, #390)
- 2026-10-08 : **Parcours de démarrage guidé livré de bout en bout — `/autopilot` (6 PRs stackées en 3 h, CI verte à chaque maillon) → six `/pr-review` APPROVE → sept commits en tête de pile → merge ff par l'API (`main` `ee17f31`).**
- **Revues** : aucun bloquant, un verdict indépendant par PR (commentaires 20659 à 20730) ; `status:approved` posé par les seuls relecteurs, les workers ayant laissé `status:review` (skill corrigé).
- **Décision de Max : voie A** sur le critère de #398 « pas de « Fait. » pour un import qui n'ajoute rien ». Le relecteur a corrigé le rapport du run : les doublons seuls sont sans effet (leurs transactions sont venues avec un fichier `completed`) ; le vrai cas est l'import sans ligne valide (en-tête seul, format mal détecté), qui cochait l'étape 4 et faisait boucler /transactions et /import. L'étape compte désormais les `imported_files` en `completed` ayant au moins une transaction (`EXISTS` sur `transactions.file_id`, index `idx_transactions_file`), avec une entrée Corrigé : la v0.17.0 a déjà le défaut.
- **Autres suivis en tête** : D9 lit son drapeau hors transaction, donc plus de `BEGIN`/`COMMIT` à chaque ouverture, pendant lesquels `db.ts` laisse tout passer hors verrou ; `:focus-visible { outline: revert }` sur le contrôle entouré (WCAG 2.4.7) ; carte Budget sans cible sur l'état vide ; épingles (séquence réelle du reducer, phrase selon le filtre, notification de restauration hors verrou, fuseau du test du mois) ; docs et liste QA à 44 cas.
- **Preuves** : 16 mutations sur ces correctifs, toutes rouges, dont 4 survivantes des revues ; compteur rejoué avec sqlite3 sur le schéma consolidé ; tip en worktree `npm ci` propre (sonde rc=1, build, 1904 vitest) ; CI 507. Les commits ajoutés après la revue, non relus, sont nommés dans le commentaire 20835 de #407.
- **Merge** : chaque enfant reciblé sur `main` puis mergé en ff épinglé, branches gardées jusqu'au dernier merge puis supprimées, donc 6 PRs `merged`. #384 : trois items cochés (réglés par la pile), #382 en partie.
- **Acquis** : au `/plan-run`, confronter un critère à la définition du compteur hérité qu'il suppose (celui de l'étape 4 venait de la phase 1).
- Aucune migration DB (v1→v17). (ref #396-#401, PRs #402-#407, #408, #409, #384)
- 2026-10-06 : **#390 mergée (PR #394, `main` `5910387`) — l'étape 5 de la liste de démarrage se coche, les profils secondaires rattrapent leurs migrations, et les bases sont cherchées dans le bon dossier sous Linux.**
- **Constat** : en testant l'onboarding de la v0.17.0, Max a vu que classer une transaction ne cochait pas l'étape 5.
- **Cause** : depuis la v0.1.0, `updateTransactionCategory` lie un booléen JS. tauri-plugin-sql 2.3.2 le stocke en texte `'true'`, que le compteur `= 1` ne trouve pas.
- **Deux défauts plus anciens, trouvés en corrigeant** :
- tauri-plugin-sql ne migre que `simpl_resultat.db`. Un profil secondaire gardait le schéma de sa création ; il existe sept états publiés depuis la v0.3.0.
- Le plugin range les bases sous `app_config_dir()`. `repair_migrations`, `delete_profile_db` et `compute_account_return` cherchaient sous `app_data_dir()`, dossier différent sous Linux seulement. Supprimer un profil laissait sa base sur le disque, et les rendements du Bilan s'affichaient en tirets.
- **Décisions de Max** : réparer les drapeaux à l'ouverture de chaque profil plutôt que par une v18 ; inclure le rattrapage dans #390 ; appliquer les suggestions rapides de la revue, puis merger.
- **Livré** :
- `toSqlFlag`, à l'écriture et à la restauration d'une sauvegarde ;
- `prepare_profile_db` : plancher déduit du schéma, copie `VACUUM INTO` avant la première écriture, historique `_sqlx_migrations` au format sqlx, clés étrangères actives avec une seule reprise ciblée, réparation des drapeaux texte, échec ouvert ;
- l'historique écrit dès la création d'un profil ;
- `profile_db_path` : dossier `app_config_dir()` et nom de fichier validé ;
- l'ADR 0021, et les amendements des ADR 0003 et 0005.
- **Preuves** :
- les sept états publiés sont rejoués en fixtures ;
- 22 mutations de l'auteur, toutes rouges ;
- le plan checker a refusé trois fois (dossier Linux, rejeu de v2, clés étrangères), puis Max a validé la suite ;
- `/pr-review` APPROVE sur `71147c5` ;
- les quatre suggestions rapides sont appliquées en tête (`5910387`, des tests seulement, non relu), les quatre autres sont reportées en #395 ;
- 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)
- 2026-10-05 : **Suites de licence livrées — `/autopilot` (2026-10-04, 3 workers séquentiels de 36, 39 et 41 min, CI attendue verte entre deux maillons) → trois `/pr-review` APPROVE → suivis en tête de pile → merge ff par l'API (`main` `2cbc886`).** #373 : contrôle de révocation à la saisie, en arrière-plan, sous un verrou `tauri::async_runtime::Mutex` qui sérialise lecture de l'état → tentative → requête → verdict ; écart accepté : le test de concurrence rougit sans verrou sur le verdict lu par le second contrôle, pas sur le nombre de requêtes (une sonde à deux fils de 200 rondes n'a jamais ouvert la fenêtre). #370 : `remove_license` (clé supprimée d'abord, puis le jeton ; `license.check` gardé ; libération préalable par `deactivate_idempotent` + `note_revocation`, jamais `deactivate_in`, qui supprimerait le jeton avant la clé). #367 : suppression du jeton seulement s'il est « inchangé depuis la requête » (CWE-367), sur un 403 `license_revoked` des cours et à la lecture d'une liste complète sans ligne de ce poste ; garde étendue à la liste par le worker ; lignes de Journal à code fixe. Revues : aucun bloquant ; les 5 mutations survivantes des relecteurs (relais de « déjà révoquée », deux `t` oubliés, `Storage` de `store_license_in`, jeton non UTF-8) et deux autres suggestions (rechargement après un retrait en échec, budget de 6 requêtes) appliquées dans `2cbc886`, prouvées par 2, 18, 4, 1, 10, 1 et 1 tests rouges. Tip validé en worktree `npm ci` propre (sonde rouge rc=1, build, 1498 vitest, 224 cargo), CI 489/490. **Acquis** : appliquer les suggestions de revue en tête de pile garde les têtes revues intactes, donc le merge ff épinglé reste possible maillon par maillon ; un faux `t` qui rend la clé brute rend invisible tout `t` oublié (suite : #389). Aucune migration DB (v1→v17). (ref #373, #370, #367, PRs #386-#388, #389)
## Blockers actifs ## 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. - 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.
- **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`) : - **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). - 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).
- `release.yml` sous Node 24 (#376) : `node --version` à `v24.x` dans le journal du run de release, et `verify-release.sh` vert. Le diff touchant `release.yml`, l'étape 10 de `/release` (cycle réel de mise à jour, `docs/qa-update-cycle.md`) s'applique aussi, selon la règle du skill. Le bump de `rustls` de #447 (dépendance de `tauri-plugin-updater`) la rend obligatoire lui aussi. - Répartition réservée à Base (#369), dans l'app : en Gratuite, cadenas sur le bouton Répartition de la page Transactions et écran de déverrouillage au clic, jamais la fenêtre de répartition, lignes déjà réparties toujours marquées de l'icône orange ; en Base, fenêtre de répartition comme avant. Puis dire sur la-compagnie-maximus#180 que le décalage avec le tableau comparatif déployé est résorbé.
- `release.yml` sous Node 24 (#376) : `node --version` à `v24.x` dans le journal du run de release, et `verify-release.sh` vert. Le diff touchant `release.yml`, l'étape 10 de `/release` (cycle réel de mise à jour, `docs/qa-update-cycle.md`) s'applique aussi, selon la règle du skill. Le bump de `rustls` de #447 (dépendance de `tauri-plugin-updater`) la rend obligatoire lui aussi. L'updater compilé sans `zip` (#455) est aussi dans cette release, mais ce cycle part de la v0.17.1, dont l'updater a encore `zip` : il ne prouve rien de l'updater sans `zip` (#463).
- `latest.json` à trois clés (#320, clé générique `linux-x86_64` retirée le 2026-10-11) : étape « Generate latest.json » du journal lue (un paquet retenu par type), `verify-release.sh` en `VERDICT : OK` sur `linux-x86_64-deb`, `linux-x86_64-rpm` et `windows-x86_64` (extensions, `file:` des signatures). Cycle réel depuis une installation `.rpm` (§3 de `docs/qa-update-cycle.md`) dès qu'une machine Fedora ou openSUSE est disponible, sinon « non déroulé » avec la raison dans la trace de la release. - `latest.json` à trois clés (#320, clé générique `linux-x86_64` retirée le 2026-10-11) : étape « Generate latest.json » du journal lue (un paquet retenu par type), `verify-release.sh` en `VERDICT : OK` sur `linux-x86_64-deb`, `linux-x86_64-rpm` et `windows-x86_64` (extensions, `file:` des signatures). Cycle réel depuis une installation `.rpm` (§3 de `docs/qa-update-cycle.md`) dès qu'une machine Fedora ou openSUSE est disponible, sinon « non déroulé » avec la raison dans la trace de la release.
- **Vérifications dues à la release suivante** (celle d'après le prochain tag) : cycle réel de mise à jour Windows depuis la première version qui embarque #455 (et #447), première exécution de l'installation par l'updater sans `zip` et du téléchargement par `rustls` 0.23.46 ; dû même si cette release ne touche pas l'updater (#463).
- **Le paiement est live : « aucun tag avant le go-live » n'a plus de raison d'être, et la release desktop qu'attendait le go-live est publiée (v0.16.0, 2026-10-03).** Bascule live le 2026-09-29 (Product et Price live à 19 $ CAD, webhook, variables Stripe de production, redeploy `3673d09` de maximus-api, URL `cs_live_` vérifiée), puis achat réel de contrôle le 2026-09-30 : clé sur `/merci` et par courriel, activation dans l'app, remboursement complet et licence révoquée par `charge.refunded`. **Volet serveur clos le 2026-10-03** (derniers commentaires relus le 2026-10-04) : maximus-api#62 fermée, tous critères cochés (maximus-api 0.4.2 en prod, secrets en runtime seul), avec la milestone `spec-paiements` de maximus-api (12/12) ; la-compagnie-maximus#150 (Link dans les textes légaux) livrée et fermée le 2026-10-01. **Go-live clos : la-compagnie-maximus#141** fermée le 2026-10-10 sur décision de Max, l'updater vérifié (Free refuse et invite à acheter, Base accepte ; confirmé le même jour). Restent pour mémoire, sans issue : le Dashboard Stripe (logo, paramètres Checkout, reçus automatiques, alertes d'échec de webhook) et les relevés du commentaire de fermeture. la-compagnie-maximus#184 (politique de confidentialité : données d'activation, contrôle au démarrage) est en `status:review` ; sa PR reste en brouillon jusqu'au déploiement de maximus-api#85 (commentaire du 2026-10-03). Les deux textes des suites de licence (contrôle à la saisie, retrait de la clé) y sont postés depuis le 2026-10-05 (commentaire 19724). **Vérifications manuelles sur la release publiée, toutes faites** : #365 le 2026-10-03, #346 et #350 le 2026-10-04 (voir Décisions). L'étape 10 sous Windows est faite (Michel, confirmée le 2026-10-10). - **Le paiement est live : « aucun tag avant le go-live » n'a plus de raison d'être, et la release desktop qu'attendait le go-live est publiée (v0.16.0, 2026-10-03).** Bascule live le 2026-09-29 (Product et Price live à 19 $ CAD, webhook, variables Stripe de production, redeploy `3673d09` de maximus-api, URL `cs_live_` vérifiée), puis achat réel de contrôle le 2026-09-30 : clé sur `/merci` et par courriel, activation dans l'app, remboursement complet et licence révoquée par `charge.refunded`. **Volet serveur clos le 2026-10-03** (derniers commentaires relus le 2026-10-04) : maximus-api#62 fermée, tous critères cochés (maximus-api 0.4.2 en prod, secrets en runtime seul), avec la milestone `spec-paiements` de maximus-api (12/12) ; la-compagnie-maximus#150 (Link dans les textes légaux) livrée et fermée le 2026-10-01. **Go-live clos : la-compagnie-maximus#141** fermée le 2026-10-10 sur décision de Max, l'updater vérifié (Free refuse et invite à acheter, Base accepte ; confirmé le même jour). Restent pour mémoire, sans issue : le Dashboard Stripe (logo, paramètres Checkout, reçus automatiques, alertes d'échec de webhook) et les relevés du commentaire de fermeture. la-compagnie-maximus#184 (politique de confidentialité : données d'activation, contrôle au démarrage) est en `status:review` ; sa PR reste en brouillon jusqu'au déploiement de maximus-api#85 (commentaire du 2026-10-03). Les deux textes des suites de licence (contrôle à la saisie, retrait de la clé) y sont postés depuis le 2026-10-05 (commentaire 19724). **Vérifications manuelles sur la release publiée, toutes faites** : #365 le 2026-10-03, #346 et #350 le 2026-10-04 (voir Décisions). L'étape 10 sous Windows est faite (Michel, confirmée le 2026-10-10).
- Backlog ouvert : **vague `planned-2026-09-24-paiements` close 3/3** (milestone fermée le 2026-09-30) : #270 (PR #359) et #346 (PR #362) mergées le 2026-09-27, #347 (PR #364, ferme aussi #360) mergée le 2026-09-30 ; volet site **clos** (maximus/la-compagnie-maximus#153, redirection de `/simpl-resultat` en prod). **#365 fermée le 2026-10-03** après sa vérification manuelle. **Milestone `planned-2026-10-03-suites-licence` fermée 3/3 le 2026-10-05** (#373, #370, #367 mergées, voir Décisions ; suivi #389 en `status:triage`). Hors milestone, en `status:ready` : **#369** (réserver le fractionnement de transactions à l'édition Base) et **#372** (la liste complète des versions repousse le Journal en bas de Paramètres → Systèmes). **#350 fermée le 2026-10-04** (étape 3 vérifiée en production, voir Décisions) ; #363 fermée le 2026-10-10 sur décision de Max (ses tests, absorbés par #347, sont livrés dans la v0.16.0) ; réglés côté serveur et site : maximus-api#63 (clés de 100 ans) et maximus-api#70 (réactivation d'une machine désactivée), fermées et contenues dans `3673d09`, le commit déployé à la bascule du 2026-09-29, et la-compagnie-maximus#157, fermée, dont les CGV sont publiées en tronc commun plus annexe Simpl-Résultat (annexe relue en production le 2026-09-30, plus aucune durée de 10 ans) ; côté serveur, `/v1/prices` ne vérifie pas qu'une machine est encore active (une machine désactivée garde les cours jusqu'à l'expiration de son jeton), issue maximus-api à ouvrir ; suites de #350 ouvertes le 2026-10-01 : maximus-api#82 (`status:triage` : identifiants machine bruts déjà stockés, lignes désactivées comprises ; à rehacher avant l'expiration des premiers jetons bruts, sinon une réactivation crée une seconde ligne) et la-compagnie-maximus#184 (`status:review`, voir ci-dessus) ; go-live clos (la-compagnie-maximus#141 fermée le 2026-10-10, maximus-api#62 le 2026-10-03) ; `spec-paiements` fermée ici 5/5 ; onboarding phase 2 (parcours guidé) **mergée** : milestone `planned-2026-10-06-onboarding-parcours-guide` fermée 6/6 le 2026-10-08 (#396 à #401, `ee17f31`), suivis **#408** et **#409** (`status:triage`) ; onboarding phase 1 **livrée** : milestone `planned-2026-10-03-onboarding-phase1` fermée 7/7 le 2026-10-04 (#351 à #357, mergées en avance rapide, `667e058`) ; suivis ouverts : **#384** (`status:ready`, suivis non bloquants des revues) et **#371** (`status:blocked`, hors `/autopilot` : en-têtes CSV BMO à fournir par Max depuis un compte réel, un par type de compte, chèques/épargne et Mastercard, avec des valeurs fictives) ; analyse d'origine dans `docs/analyse-onboarding-2026-09.md`, plan détaillé dans les `spec-*-onboarding-phase1.md` locaux, ignorés par git ; #271 absorbé par #301, livré ; `spec-ci-build-optimization` **fermée 4/4** le 2026-10-05 (#232 livrée le 2026-07-27 ; #234 close le 2026-10-05 avec ses quatre preuves : dispatch run 464, PR #386 run 480, run planifié 488, `actcache` à 999 Mo) ; **#376** fermée le 2026-10-12 (Node 24, PR #414). Le suivi « glob `src-tauri/**` non prouvé » ouvert avec #232 est **levé** : la PR #344 est le cas test isolé attendu (`Cargo.lock` + 2 `.md` seulement) — `check-rust` a bien tourné, `check-frontend` a bien été sauté par sa denylist. - Backlog ouvert : **vague `planned-2026-09-24-paiements` close 3/3** (milestone fermée le 2026-09-30) : #270 (PR #359) et #346 (PR #362) mergées le 2026-09-27, #347 (PR #364, ferme aussi #360) mergée le 2026-09-30 ; volet site **clos** (maximus/la-compagnie-maximus#153, redirection de `/simpl-resultat` en prod). **#365 fermée le 2026-10-03** après sa vérification manuelle. **Milestone `planned-2026-10-03-suites-licence` fermée 3/3 le 2026-10-05** (#373, #370, #367 mergées, voir Décisions ; suivi #389 en `status:triage`). Hors milestone : **#369** (réserver le fractionnement de transactions à l'édition Base) **fermée le 2026-10-17** (PR #452, voir Décisions) ; **#372** (la liste complète des versions repousse le Journal en bas de Paramètres → Systèmes) reste en `status:ready`. **#350 fermée le 2026-10-04** (étape 3 vérifiée en production, voir Décisions) ; #363 fermée le 2026-10-10 sur décision de Max (ses tests, absorbés par #347, sont livrés dans la v0.16.0) ; réglés côté serveur et site : maximus-api#63 (clés de 100 ans) et maximus-api#70 (réactivation d'une machine désactivée), fermées et contenues dans `3673d09`, le commit déployé à la bascule du 2026-09-29, et la-compagnie-maximus#157, fermée, dont les CGV sont publiées en tronc commun plus annexe Simpl-Résultat (annexe relue en production le 2026-09-30, plus aucune durée de 10 ans) ; côté serveur, `/v1/prices` ne vérifie pas qu'une machine est encore active (une machine désactivée garde les cours jusqu'à l'expiration de son jeton), issue maximus-api à ouvrir ; suites de #350 ouvertes le 2026-10-01 : maximus-api#82 (`status:triage` : identifiants machine bruts déjà stockés, lignes désactivées comprises ; à rehacher avant l'expiration des premiers jetons bruts, sinon une réactivation crée une seconde ligne) et la-compagnie-maximus#184 (`status:review`, voir ci-dessus) ; go-live clos (la-compagnie-maximus#141 fermée le 2026-10-10, maximus-api#62 le 2026-10-03) ; `spec-paiements` fermée ici 5/5 ; onboarding phase 2 (parcours guidé) **mergée** : milestone `planned-2026-10-06-onboarding-parcours-guide` fermée 6/6 le 2026-10-08 (#396 à #401, `ee17f31`), suivis **#408** et **#409** (`status:triage`) ; onboarding phase 1 **livrée** : milestone `planned-2026-10-03-onboarding-phase1` fermée 7/7 le 2026-10-04 (#351 à #357, mergées en avance rapide, `667e058`) ; suivis ouverts : **#384** (`status:ready`, suivis non bloquants des revues) et **#371** (`status:blocked`, hors `/autopilot` : en-têtes CSV BMO à fournir par Max depuis un compte réel, un par type de compte, chèques/épargne et Mastercard, avec des valeurs fictives) ; analyse d'origine dans `docs/analyse-onboarding-2026-09.md`, plan détaillé dans les `spec-*-onboarding-phase1.md` locaux, ignorés par git ; #271 absorbé par #301, livré ; `spec-ci-build-optimization` **fermée 4/4** le 2026-10-05 (#232 livrée le 2026-07-27 ; #234 close le 2026-10-05 avec ses quatre preuves : dispatch run 464, PR #386 run 480, run planifié 488, `actcache` à 999 Mo) ; **#376** fermée le 2026-10-12 (Node 24, PR #414). Le suivi « glob `src-tauri/**` non prouvé » ouvert avec #232 est **levé** : la PR #344 est le cas test isolé attendu (`Cargo.lock` + 2 `.md` seulement) — `check-rust` a bien tourné, `check-frontend` a bien été sauté par sa denylist.
- Suivis des advisories : **#312, #313 et #315 fermées le 2026-08-15** (PRs #321/#322 mergées). **#314 fermée le 2026-08-15, prémisse infirmée** — elle affirmait « zéro run `schedule` », or `audit.yml` tire tous les jours à 06:00 UTC (**19 runs `schedule` verts** du 07-28 au 08-15, sans trou) ; elle a été écrite le 07-27 vers 23:56Z, ~6 h avant le premier tir programmé possible, et décrivait donc une fenêtre trop courte, pas un scheduler en panne. Ces 19 runs confirment au passage que `.cargo/audit.toml` est bien lu en conteneur **sur `main`** — l'ADR 0018 ne l'avait vérifié que par un `workflow_dispatch` sur une branche. **#343 fermée le 2026-09-04** (PR #344) — RUSTSEC-2026-0258 sur `h2`, la seule vulnérabilité qui rougissait l'audit depuis le 08-19 ; l'audit est de nouveau vert sur `main`, prouvé par `workflow_dispatch` (run 393) et non déduit du run de PR. **#348 fermée le 2026-09-21** (PR #349) — RUSTSEC-2026-0285 sur `rustls`, seule vulnérabilité rougissant l'audit depuis le 09-15 (7 runs, 405→411) ; vert reprouvé par `workflow_dispatch` sur `main` (run 414 : 666 paquets, zéro vulnérabilité, 12 warnings autorisés). Deux advisories en cinq semaines sur le même chemin, celui de la mise à jour automatique. **#447 fermée le 2026-10-16** (PR #448, run 556 vert), quatre avis `rustls` sur ce même chemin ; le Défenseur n'audite pas le Rust (agent-defenseurs#186), `audit.yml` reste le seul canal pour les avis RustSec. **#317** fermée le 2026-10-12 par la PR #417 (#358). Reste **#319** (déclencheur de retrait pour `rsa`, désormais **seule** entrée de `.cargo/audit.toml` — le garde-fou est passé à 2 vérifications). **#345** fermée le 2026-10-12 (PR #415) : garde `Verify windows-sys pinning` dans `check-rust.yml` et ADR 0023, qui portent la mesure, le coût (3 contre 5 versions compilées pour Windows) et la condition de sortie. Côté npm, **#358** fermée le 2026-10-12 (PR #417) : lock régénéré par `npm@11.21.0` dans les bornes de `package.json`, `npm audit` 8 → 0 au 2026-10-10. - Suivis des advisories : **#312, #313 et #315 fermées le 2026-08-15** (PRs #321/#322 mergées). **#314 fermée le 2026-08-15, prémisse infirmée** — elle affirmait « zéro run `schedule` », or `audit.yml` tire tous les jours à 06:00 UTC (**19 runs `schedule` verts** du 07-28 au 08-15, sans trou) ; elle a été écrite le 07-27 vers 23:56Z, ~6 h avant le premier tir programmé possible, et décrivait donc une fenêtre trop courte, pas un scheduler en panne. Ces 19 runs confirment au passage que `.cargo/audit.toml` est bien lu en conteneur **sur `main`** — l'ADR 0018 ne l'avait vérifié que par un `workflow_dispatch` sur une branche. **#343 fermée le 2026-09-04** (PR #344) — RUSTSEC-2026-0258 sur `h2`, la seule vulnérabilité qui rougissait l'audit depuis le 08-19 ; l'audit est de nouveau vert sur `main`, prouvé par `workflow_dispatch` (run 393) et non déduit du run de PR. **#348 fermée le 2026-09-21** (PR #349) — RUSTSEC-2026-0285 sur `rustls`, seule vulnérabilité rougissant l'audit depuis le 09-15 (7 runs, 405→411) ; vert reprouvé par `workflow_dispatch` sur `main` (run 414 : 666 paquets, zéro vulnérabilité, 12 warnings autorisés). Deux advisories en cinq semaines sur le même chemin, celui de la mise à jour automatique. **#447 fermée le 2026-10-16** (PR #448, run 556 vert), quatre avis `rustls` sur ce même chemin ; le Défenseur n'audite pas le Rust (agent-defenseurs#186), `audit.yml` reste le seul canal pour les avis RustSec. **#455 fermée le 2026-10-18** (PR #459, run 564 vert), trois avis `zip` sur ce même chemin, retirés avec la feature `zip` de l'updater plutôt que supprimés. **#317** fermée le 2026-10-12 par la PR #417 (#358). Reste **#319** (déclencheur de retrait pour `rsa`, désormais **seule** entrée de `.cargo/audit.toml` — le garde-fou est passé à 2 vérifications). **#345** fermée le 2026-10-12 (PR #415) : garde `Verify windows-sys pinning` dans `check-rust.yml` et ADR 0023, qui portent la mesure, le coût (3 contre 5 versions compilées pour Windows) et la condition de sortie. Côté npm, **#358** fermée le 2026-10-12 (PR #417) : lock régénéré par `npm@11.21.0` dans les bornes de `package.json`, `npm audit` 8 → 0 au 2026-10-10.
- **#320** fermée le 2026-10-12 (PR #416, clé générique retirée par la PR #418) : une clé `latest.json` par paquet Linux, à vérifier au prochain tag (puce « Vérifications dues à la prochaine release ») ; le §3 de `docs/qa-update-cycle.md` est devenu la procédure de test rpm. - **#320** fermée le 2026-10-12 (PR #416, clé générique retirée par la PR #418) : une clé `latest.json` par paquet Linux, à vérifier au prochain tag (puce « Vérifications dues à la prochaine release ») ; le §3 de `docs/qa-update-cycle.md` est devenu la procédure de test rpm.
- Suivis ouverts du chantier import (milestone fermée) : le refus du format « montant absolu + indicateur D/C » ne scanne que `amountCol ± 1`, donc une colonne D/C placée ailleurs passe encore ; dérives de comptes préexistantes dans `CLAUDE.md` et `docs/architecture.md` (composants, pages, services, `Version actuelle : 0.6.3` vs tag v0.14.0) mesurées et documentées en revue, non corrigées. - Suivis ouverts du chantier import (milestone fermée) : le refus du format « montant absolu + indicateur D/C » ne scanne que `amountCol ± 1`, donc une colonne D/C placée ailleurs passe encore ; dérives de comptes préexistantes dans `CLAUDE.md` et `docs/architecture.md` (composants, pages, services, `Version actuelle : 0.6.3` vs tag v0.14.0) mesurées et documentées en revue, non corrigées.
- **Étape 10 de la release v0.15.0 consignée le 2026-09-13** — plus un blocker : cycle réel v0.14.0 → v0.15.0 validé par les deux testeurs ; trace rédigée pour le corps de la release (`PATCH` à appliquer par Max) ; invite d'authentification Linux non consignée ; #320 reste ouverte (`.rpm`). Deux restes : (1) le texte de la carte Compte Maximus (« Le compte est requis uniquement pour les fonctionnalités Premium », hérité de v0.7) contredit v0.15.0 où Premium vient de la clé, et a vraisemblablement conduit un testeur à se connecter pour rien (code de vérification Logto reçu par courriel) — reformulation livrée par #346 (PR #362), avec une réserve notée dans l'ADR 0017 (exacte tant que le chemin abonnement → Premium reste dormant) ; (2) le flux Logto exact (connexion par code, mot de passe oublié ou inscription) reste à lire dans le journal d'audit. Position produit : v0.17.1 (2026-10-08) ; `[Unreleased]` non vide (Added, Changed, Fixed, Security), en attente du prochain tag. - **Étape 10 de la release v0.15.0 consignée le 2026-09-13** — plus un blocker : cycle réel v0.14.0 → v0.15.0 validé par les deux testeurs ; trace rédigée pour le corps de la release (`PATCH` à appliquer par Max) ; invite d'authentification Linux non consignée ; #320 reste ouverte (`.rpm`). Deux restes : (1) le texte de la carte Compte Maximus (« Le compte est requis uniquement pour les fonctionnalités Premium », hérité de v0.7) contredit v0.15.0 où Premium vient de la clé, et a vraisemblablement conduit un testeur à se connecter pour rien (code de vérification Logto reçu par courriel) — reformulation livrée par #346 (PR #362), avec une réserve notée dans l'ADR 0017 (exacte tant que le chemin abonnement → Premium reste dormant) ; (2) le flux Logto exact (connexion par code, mot de passe oublié ou inscription) reste à lire dans le journal d'audit. Position produit : v0.17.1 (2026-10-08) ; `[Unreleased]` non vide (Added, Changed, Fixed, Security), en attente du prochain tag.

View file

@ -26,6 +26,8 @@
> >
> **Ce que cet amendement ne change pas.** Le §2 tient toujours : c'est un soft-paywall, pas un contrôle d'accès. Bloquer le serveur de licences, rester hors ligne ou supprimer `license.check` suffit à garder l'édition (dans ce dernier cas jusqu'au contrôle suivant, dû dès le prochain démarrage ou la prochaine saisie de la clé), et une app laissée ouverte n'est recontrôlée qu'à son redémarrage ou à la saisie d'une clé (#373). La révocation aligne le cas ordinaire, un acheteur remboursé qui ne fait rien de particulier, sur ce que disent les conditions de vente. La mention du contrôle dans la politique de confidentialité du site, y compris sa poursuite après une révocation, est suivie par maximus/la-compagnie-maximus#184. > **Ce que cet amendement ne change pas.** Le §2 tient toujours : c'est un soft-paywall, pas un contrôle d'accès. Bloquer le serveur de licences, rester hors ligne ou supprimer `license.check` suffit à garder l'édition (dans ce dernier cas jusqu'au contrôle suivant, dû dès le prochain démarrage ou la prochaine saisie de la clé), et une app laissée ouverte n'est recontrôlée qu'à son redémarrage ou à la saisie d'une clé (#373). La révocation aligne le cas ordinaire, un acheteur remboursé qui ne fait rien de particulier, sur ce que disent les conditions de vente. La mention du contrôle dans la politique de confidentialité du site, y compris sa poursuite après une révocation, est suivie par maximus/la-compagnie-maximus#184.
> **Amendement du 2026-10-16 (#369)** — la répartition d'une transaction sur plusieurs catégories (bouton « Répartition » de la page Transactions) passe dans l'édition Base, avec les ajustements, sur décision de Max du 2026-10-03 (analyse de maximus/la-compagnie-maximus#180). L'écran de déverrouillage le promettait déjà (`upsell.features.adjustments`) alors que l'app ne la verrouillait pas : le code s'aligne sur l'offre, il ne la change pas. La page Transactions reste Free ; seul son bouton est gaté, par la clé `adjustments`. En Gratuite, le bouton garde sa place et son icône, 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, répartie ou non. Tant que la licence n'est pas `ready`, il n'affiche aucun cadenas et ne fait rien (`splitAccessFor`, plus strict que `isProfileSwitchLocked` : `allowed` n'y court-circuite pas `ready`). Conformément au §5, les répartitions existantes (`transactions.parent_transaction_id`, `is_split`) restent en base, comptées par les rapports et le tableau de bord, et signalées par l'icône orange ; seules leur ouverture et leur modification sont verrouillées. Écartés : une fenêtre de répartition en lecture seule, et l'annulation d'une répartition permise en Gratuite.
## Contexte ## Contexte
Simpl'Résultat vend trois éditions — **Gratuite** (`free`), **Base** (`base`), **Premium** (`premium`) — résolues localement depuis une clé de licence JWT Ed25519 (liée à la machine côté serveur seulement depuis #347, voir l'amendement en tête ; ou depuis un abonnement Compte Maximus pour Premium). Jusqu'au chantier #297-#301, cette édition n'était presque pas consommée : seul le gate `auto-update` (Rust, `check_entitlement`) et le fetch de cours (serveur) en dépendaient. Tous les modules de l'app étaient accessibles quelle que soit l'édition. Simpl'Résultat vend trois éditions — **Gratuite** (`free`), **Base** (`base`), **Premium** (`premium`) — résolues localement depuis une clé de licence JWT Ed25519 (liée à la machine côté serveur seulement depuis #347, voir l'amendement en tête ; ou depuis un abonnement Compte Maximus pour Premium). Jusqu'au chantier #297-#301, cette édition n'était presque pas consommée : seul le gate `auto-update` (Rust, `check_entitlement`) et le fetch de cours (serveur) en dépendaient. Tous les modules de l'app étaient accessibles quelle que soit l'édition.
@ -34,12 +36,12 @@ Le recadrage du périmètre des abonnements par Max fixe la matrice suivante :
| Module | Gratuite | Base | Premium | | Module | Gratuite | Base | Premium |
|---|---|---|---| |---|---|---|---|
| Tableau de bord, Import, Transactions, Catégories | ✓ | ✓ | ✓ | | Tableau de bord, Import, Transactions (sans la Répartition, #369), Catégories | ✓ | ✓ | ✓ |
| Rapports : hub `/reports` + Tendances | ✓ | ✓ | ✓ | | Rapports : hub `/reports` + Tendances | ✓ | ✓ | ✓ |
| Export / import chiffré, Changelog, Paramètres | ✓ | ✓ | ✓ | | Export / import chiffré, Changelog, Paramètres | ✓ | ✓ | ✓ |
| Profil (un seul) | ✓ | ✓ | ✓ | | Profil (un seul) | ✓ | ✓ | ✓ |
| Budget | — | ✓ | ✓ | | Budget | — | ✓ | ✓ |
| Ajustements | — | ✓ | ✓ | | Ajustements, Répartition des transactions (bouton de la page Transactions, #369) | — | ✓ | ✓ |
| Rapports avancés (Faits saillants, Comparables, Analyse par catégorie, Cartes) | — | ✓ | ✓ | | Rapports avancés (Faits saillants, Comparables, Analyse par catégorie, Cartes) | — | ✓ | ✓ |
| Profils multiples | — | ✓ | ✓ | | Profils multiples | — | ✓ | ✓ |
| Mises à jour automatiques | — | ✓ | ✓ | | Mises à jour automatiques | — | ✓ | ✓ |
@ -83,11 +85,11 @@ Le gating est appliqué **uniquement dans l'interface** (routes, navigation, poi
### 4. Upsell verrouillé visible, jamais masqué ### 4. Upsell verrouillé visible, jamais masqué
Les modules non inclus dans l'édition restent **visibles et cliquables** : cadenas dans la Sidebar (`NavLock`, sur `budget`/`adjustments`/`balance`) et sur les tuiles de rapports avancés du hub, affichés seulement si `ready && !allowed`. Les routes gatées sont enveloppées dans des layout-routes `RequireFeature` (loader neutre si `!ready`, `<Outlet/>` sinon) qui rendent `UpsellGate` en cas de refus : écran verrouillé avec le tier requis, un CTA « Obtenir \<tier\> » **désactivé** avec la mention « bientôt » (le flux d'achat en ligne sera câblé par #270/Stripe ; voir l'amendement en tête), et « J'ai déjà une clé » qui mène à la carte licence (`/settings/users`). Le hub `/reports` et `/reports/trends` restent Free et **hors de tout gate**. Les modules non inclus dans l'édition restent **visibles et cliquables** : cadenas dans la Sidebar (`NavLock`, sur `budget`/`adjustments`/`balance`) et sur les tuiles de rapports avancés du hub, affichés seulement si `ready && !allowed`. Les routes gatées sont enveloppées dans des layout-routes `RequireFeature` (loader neutre si `!ready`, `<Outlet/>` sinon) qui rendent `UpsellGate` en cas de refus : écran verrouillé avec le tier requis, un CTA « Obtenir \<tier\> » **désactivé** avec la mention « bientôt » (le flux d'achat en ligne sera câblé par #270/Stripe ; voir l'amendement en tête), et « J'ai déjà une clé » qui mène à la carte licence (`/settings/users`). Le hub `/reports` et `/reports/trends` restent Free et **hors de tout gate**. Depuis #369, une action gatée peut aussi vivre dans une page Free : le bouton Répartition de la page Transactions porte le cadenas (`SplitButton`, si `ready && !allowed` pour `adjustments`) et ouvre `UpsellGate` dans une fenêtre (`UpsellDialog`).
### 5. Durcissement sec, non destructif ### 5. Durcissement sec, non destructif
Le gating **bloque l'accès, jamais les données**. Rien n'est supprimé ni migré quand l'édition baisse (expiration, clé retirée, clé révoquée depuis #365 ; un changement de machine ne la fait plus baisser depuis #347, voir les amendements en tête) : les budgets, ajustements, snapshots de bilan et profils restent intacts dans leurs bases SQLite, et tout réapparaît dès qu'une clé valide est saisie. Cas particulier multi-profils (`src/shared/profileGate.ts`, prédicats purs) : un utilisateur Free garde **toujours** l'accès à son profil actif ; seuls le passage à un autre profil et la création d'un profil supplémentaire sont verrouillés, la création étant gatée au point unique `ProfileFormModal` (mode upsell compact ; voir l'amendement en tête). Si aucun profil actif ne se résout (état dégénéré), rien n'est verrouillé — on n'enferme jamais l'utilisateur hors de tous ses profils. Le gating **bloque l'accès, jamais les données**. Rien n'est supprimé ni migré quand l'édition baisse (expiration, clé retirée, clé révoquée depuis #365 ; un changement de machine ne la fait plus baisser depuis #347, voir les amendements en tête) : les budgets, ajustements, snapshots de bilan et profils restent intacts dans leurs bases SQLite, et tout réapparaît dès qu'une clé valide est saisie. Cas particulier multi-profils (`src/shared/profileGate.ts`, prédicats purs) : un utilisateur Free garde **toujours** l'accès à son profil actif ; seuls le passage à un autre profil et la création d'un profil supplémentaire sont verrouillés, la création étant gatée au point unique `ProfileFormModal` (mode upsell compact ; voir l'amendement en tête). Si aucun profil actif ne se résout (état dégénéré), rien n'est verrouillé — on n'enferme jamais l'utilisateur hors de tous ses profils. Même règle pour les répartitions de transactions (#369) : en Gratuite, elles restent comptées par les rapports et signalées dans la liste ; seules leur ouverture et leur modification sont verrouillées.
### 6. Dev-override compilé hors des builds normaux ### 6. Dev-override compilé hors des builds normaux
@ -144,6 +146,7 @@ Faire porter tout le gating par le tableau signé de chaque licence. Rejeté : c
- `src/hooks/useEntitlement.ts` — `{ allowed, ready }` ; `src/hooks/useIsPremium.ts` — raccourci Premium - `src/hooks/useEntitlement.ts` — `{ allowed, ready }` ; `src/hooks/useIsPremium.ts` — raccourci Premium
- `src/components/shared/RequireFeature.tsx` / `UpsellGate.tsx` — garde de route + écran verrouillé - `src/components/shared/RequireFeature.tsx` / `UpsellGate.tsx` — garde de route + écran verrouillé
- `src/shared/profileGate.ts` — prédicats multi-profils non destructifs ; `ProfileFormModal` (point unique de création) - `src/shared/profileGate.ts` — prédicats multi-profils non destructifs ; `ProfileFormModal` (point unique de création)
- `src/shared/splitGate.ts` — accès du bouton Répartition (`open`, `locked`, `pending` ; #369) ; `src/components/transactions/SplitButton.tsx` ; `src/components/shared/UpsellDialog.tsx` — écran verrouillé dans une fenêtre
- `src-tauri/src/commands/entitlements.rs` — `FEATURE_TIERS`, `is_entitled` (CWE-863) ; `license_commands.rs` — `current_entitlements` (la clé seule depuis #347, et son état de révocation depuis #365), `check_revocation_in` (contrôle au démarrage et à la saisie d'une clé, ce dernier depuis #373), `check_revocation_in_turn` (un contrôle à la fois, #373), `remove_license_in` (retrait de la clé, `license.check` gardé, #370), `dev_override_edition` (CWE-489) - `src-tauri/src/commands/entitlements.rs` — `FEATURE_TIERS`, `is_entitled` (CWE-863) ; `license_commands.rs` — `current_entitlements` (la clé seule depuis #347, et son état de révocation depuis #365), `check_revocation_in` (contrôle au démarrage et à la saisie d'une clé, ce dernier depuis #373), `check_revocation_in_turn` (un contrôle à la fois, #373), `remove_license_in` (retrait de la clé, `license.check` gardé, #370), `dev_override_edition` (CWE-489)
- [ADR 0009](0009-proxy-price-fetching-via-maximus-api.md) / [ADR 0011](0011-providers-best-effort-yahoo.md) — le gate serveur des cours, seul enforcement dur - [ADR 0009](0009-proxy-price-fetching-via-maximus-api.md) / [ADR 0011](0011-providers-best-effort-yahoo.md) — le gate serveur des cours, seul enforcement dur
- Issues #297 → #302 (milestone `planned-2026-07-19-feature-gating`) ; #271 (absorbée) ; #270 (URL d'achat localisée) ; #346 (CTA d'achat actif, voir l'amendement en tête) ; #347 (l'édition suit la seule clé) ; #365 (révocation vérifiée en ligne) ; #373 (contrôle à la saisie d'une clé, un contrôle à la fois) ; #370 (retrait de la clé enregistrée) ; #367 (jeton d'activation périmé laissé par la v0.15.0) - Issues #297 → #302 (milestone `planned-2026-07-19-feature-gating`) ; #271 (absorbée) ; #270 (URL d'achat localisée) ; #346 (CTA d'achat actif, voir l'amendement en tête) ; #347 (l'édition suit la seule clé) ; #365 (révocation vérifiée en ligne) ; #373 (contrôle à la saisie d'une clé, un contrôle à la fois) ; #370 (retrait de la clé enregistrée) ; #367 (jeton d'activation périmé laissé par la v0.15.0) ; #369 (Répartition réservée à l'édition Base, voir l'amendement en tête)

View file

@ -2,13 +2,15 @@
- Status: **Accepted** - Status: **Accepted**
- Date: 2026-07-27 - Date: 2026-07-27
- Issues: #310 (les 9 advisories découvertes, cette décision), #312 (déclencheur de retrait des entrées `quick-xml`), #314 (le cron de `audit.yml` n'a jamais démarré) - Issues: #310 (les 9 advisories découvertes, cette décision), #312 (déclencheur de retrait des entrées `quick-xml`), #314 (le cron de `audit.yml` n'a jamais démarré), #455 (canari `tar` → `webkit2gtk`)
- S'appuie sur #232 (split de `check.yml`, création de `audit.yml`, retrait du `|| true` qui masquait la sortie de `cargo audit`) - S'appuie sur #232 (split de `check.yml`, création de `audit.yml`, retrait du `|| true` qui masquait la sortie de `cargo audit`)
> **Amendement du 2026-07-27 (#312/#313)** — la décision et ses trois règles sont inchangées ; seules les entrées ont bougé. Les deux advisories `quick-xml` ont **quitté la liste le jour même** : `plist` 1.10.0 tire `quick-xml` 0.41.0, qui porte le correctif, dans la borne existante de `tauri`. Elles sont donc **résolues, pas acceptées**, et le mécanisme a fonctionné comme prévu — la vérification par cible a montré qu'un correctif était devenu atteignable, et l'entrée est partie. Il ne reste que `RUSTSEC-2023-0071` (`rsa`), et le garde-fou ne boucle plus que sur ce crate. > **Amendement du 2026-07-27 (#312/#313)** — la décision et ses trois règles sont inchangées ; seules les entrées ont bougé. Les deux advisories `quick-xml` ont **quitté la liste le jour même** : `plist` 1.10.0 tire `quick-xml` 0.41.0, qui porte le correctif, dans la borne existante de `tauri`. Elles sont donc **résolues, pas acceptées**, et le mécanisme a fonctionné comme prévu — la vérification par cible a montré qu'un correctif était devenu atteignable, et l'entrée est partie. Il ne reste que `RUSTSEC-2023-0071` (`rsa`), et le garde-fou ne boucle plus que sur ce crate.
> >
> Trois passages ci-dessous sont **datés du 2026-07-27 avant ce bump** et à lire comme historiques : `quick-xml` comme exemple de l'insuffisance de `--target all` (règle 1), l'entrée `quick-xml` du tableau de contexte, et l'alternative « override / `[patch.crates-io]` » — cette dernière **n'est plus vraie**, `plist` 1.10.0 déclarant désormais `quick-xml ^0.41.0`. L'argument de fond qu'elle illustre (`[patch.crates-io]` ne franchit pas une frontière semver-incompatible) reste correct, seul le cas d'espèce a disparu. > Trois passages ci-dessous sont **datés du 2026-07-27 avant ce bump** et à lire comme historiques : `quick-xml` comme exemple de l'insuffisance de `--target all` (règle 1), l'entrée `quick-xml` du tableau de contexte, et l'alternative « override / `[patch.crates-io]` » — cette dernière **n'est plus vraie**, `plist` 1.10.0 déclarant désormais `quick-xml ^0.41.0`. L'argument de fond qu'elle illustre (`[patch.crates-io]` ne franchit pas une frontière semver-incompatible) reste correct, seul le cas d'espèce a disparu.
> **Amendement du 2026-10-17 (#455)** — le canari du garde-fou change ; la décision et ses trois règles restent. Pour retirer `zip` 4.6.1 (RUSTSEC-2026-0367 à -0369, sans version corrigée compatible avec l'updater), `tauri-plugin-updater` est compilé sans sa feature `zip`, qui tirait aussi `tar` : `tar` a quitté le graphe Linux, et le canari d'origine aurait rougi par construction. Il est remplacé par `webkit2gtk`, la webview Linux de Tauri (`wry`), qu'un build Linux de Tauri v2 ne peut pas perdre. Le choix garde la propriété qui faisait la valeur de `tar` : une dépendance présente sur `x86_64-unknown-linux-gnu` et absente de `x86_64-pc-windows-msvc`, dont la présence prouve aussi que `--target` résout les arêtes conditionnelles sur lesquelles repose la règle 1. `tauri`, envisagé à l'analyse de #455, est inconditionnel et n'aurait prouvé que l'affichage de `cargo tree`.
## Contexte ## Contexte
#232 a retiré le `|| true` qui avalait la sortie de `cargo audit` et créé `audit.yml`, un job quotidien (06:00 UTC) **volontairement bloquant** : il *est* le canal de notification pour les advisories publiées entre deux PR Rust, `check-rust.yml` ne tournant que sur ~1 PR sur 40. #232 a retiré le `|| true` qui avalait la sortie de `cargo audit` et créé `audit.yml`, un job quotidien (06:00 UTC) **volontairement bloquant** : il *est* le canal de notification pour les advisories publiées entre deux PR Rust, `check-rust.yml` ne tournant que sur ~1 PR sur 40.
@ -52,7 +54,7 @@ L'atteignabilité est une propriété du graphe **résolu aujourd'hui**, pas une
Trois détails la rendent fiable plutôt que décorative : Trois détails la rendent fiable plutôt que décorative :
- le code de sortie de `cargo tree` est testé séparément de sa sortie standard — un crate absent sort en 0 avec un stdout vide, tandis qu'un échec de `cargo tree` sort en non-zéro avec un stdout vide lui aussi ; sans cette distinction, une panne de l'outil se lirait comme une preuve d'absence ; - le code de sortie de `cargo tree` est testé séparément de sa sortie standard — un crate absent sort en 0 avec un stdout vide, tandis qu'un échec de `cargo tree` sort en non-zéro avec un stdout vide lui aussi ; sans cette distinction, une panne de l'outil se lirait comme une preuve d'absence ;
- un **canari** (`tar`, dépendance réellement présente) doit être trouvé à chaque exécution, faute de quoi le silence de la boucle ne prouve rien ; - un **canari** (`webkit2gtk`, dépendance réellement présente, et seulement sous Linux ; `tar` jusqu'à #455) doit être trouvé à chaque exécution, faute de quoi le silence de la boucle ne prouve rien ;
- `.cargo/**` est ajouté aux `paths` de `check-rust.yml` pour qu'une PR ne touchant que la liste déclenche bien la vérification. - `.cargo/**` est ajouté aux `paths` de `check-rust.yml` pour qu'une PR ne touchant que la liste déclenche bien la vérification.
Le pendant — une entrée devenue *inutile*, que rien ne signale — est couvert par une issue de suivi portant la condition de retrait : #312 l'a joué pour `quick-xml` (et a servi : le déclencheur avait sauté sans que personne le remarque), #319 le fait pour `rsa`. **Toute entrée de la liste doit en avoir une**, sinon le garde-fou ne couvre qu'une moitié du risque. Le pendant — une entrée devenue *inutile*, que rien ne signale — est couvert par une issue de suivi portant la condition de retrait : #312 l'a joué pour `quick-xml` (et a servi : le déclencheur avait sauté sans que personne le remarque), #319 le fait pour `rsa`. **Toute entrée de la liste doit en avoir une**, sinon le garde-fou ne couvre qu'une moitié du risque.
@ -91,4 +93,4 @@ Le pendant — une entrée devenue *inutile*, que rien ne signale — est couver
- [`.forgejo/workflows/check-rust.yml`](../../.forgejo/workflows/check-rust.yml) — étape « Verify suppressed advisories are still unreachable » - [`.forgejo/workflows/check-rust.yml`](../../.forgejo/workflows/check-rust.yml) — étape « Verify suppressed advisories are still unreachable »
- [`.forgejo/workflows/audit.yml`](../../.forgejo/workflows/audit.yml) — le gate quotidien bloquant - [`.forgejo/workflows/audit.yml`](../../.forgejo/workflows/audit.yml) — le gate quotidien bloquant
- [ADR 0009](0009-proxy-price-fetching-via-maximus-api.md) — récupération de cours via `reqwest`, l'un des deux chemins qui rendent `rustls-webpki` atteignable - [ADR 0009](0009-proxy-price-fetching-via-maximus-api.md) — récupération de cours via `reqwest`, l'un des deux chemins qui rendent `rustls-webpki` atteignable
- Issues #310 (cette décision), #312 (retrait `quick-xml`, **fait le 2026-07-27**), #313 (`tauri-plugin-deep-link` yanked, fait), #314 (cron jamais déclenché), #315 (smoke-test du cycle de mise à jour), #319 (déclencheur de retrait `rsa`, la dernière entrée restante) - Issues #310 (cette décision), #312 (retrait `quick-xml`, **fait le 2026-07-27**), #313 (`tauri-plugin-deep-link` yanked, fait), #314 (cron jamais déclenché), #315 (smoke-test du cycle de mise à jour), #319 (déclencheur de retrait `rsa`, la dernière entrée restante), #455 (updater sans sa feature `zip`, canari `tar` → `webkit2gtk`)

View file

@ -38,13 +38,13 @@ simpl-resultat/
│ │ ├── profile/ # 3 composants (PIN, formulaire, switcher) │ │ ├── profile/ # 3 composants (PIN, formulaire, switcher)
│ │ ├── reports/ # ~25 composants (hub, faits saillants, tendances, comparables, zoom catégorie) │ │ ├── reports/ # ~25 composants (hub, faits saillants, tendances, comparables, zoom catégorie)
│ │ ├── settings/ # 17 composants (dont LogViewerCard, LicenseCard, LicenseKeyForm, AccountCard, DocsContent et DocsSubsections) │ │ ├── settings/ # 17 composants (dont LogViewerCard, LicenseCard, LicenseKeyForm, AccountCard, DocsContent et DocsSubsections)
│ │ ├── shared/ # 12 composants réutilisables (dont RequireFeature, UpsellGate, PurchaseButton, EmptyState) │ │ ├── shared/ # 14 composants réutilisables (dont RequireFeature, UpsellGate, UpsellDialog, PurchaseButton, EmptyState)
│ │ └── transactions/ # 5 composants │ │ └── transactions/ # 6 composants (dont SplitButton, #369)
│ ├── contexts/ # LicenseContext (licence machine) + ProfileContext (état global profil) + OnboardingContext (parcours de démarrage, #396) │ ├── contexts/ # LicenseContext (licence machine) + ProfileContext (état global profil) + OnboardingContext (parcours de démarrage, #396)
│ ├── hooks/ # 29 hooks custom (useReducer, 5 hooks rapports par domaine) │ ├── hooks/ # 29 hooks custom (useReducer, 5 hooks rapports par domaine)
│ ├── pages/ # 14 pages (dont 4 sous-pages rapports) │ ├── pages/ # 14 pages (dont 4 sous-pages rapports)
│ ├── services/ # 26 services métier (20 détaillés dans la section « Services TypeScript ») │ ├── services/ # 26 services métier (20 détaillés dans la section « Services TypeScript »)
│ ├── shared/ # Types, constantes, matrice d'entitlements (entitlements.ts), gate profils (profileGate.ts), CTA d'upsell (upsellCta.ts), URL d'achat (purchaseUrl.ts), échéance de licence (licenseExpiry.ts) │ ├── shared/ # Types, constantes, matrice d'entitlements (entitlements.ts), gate profils (profileGate.ts), gate de la Répartition (splitGate.ts), CTA d'upsell (upsellCta.ts), URL d'achat (purchaseUrl.ts), échéance de licence (licenseExpiry.ts)
│ ├── utils/ # 16 utilitaires purs (parsing montants/dates, détection CSV, codec de format d'import, dictionnaire lexical, signatures de banques, charts, drapeaux SQL 0/1, liste et parcours de démarrage) │ ├── utils/ # 16 utilitaires purs (parsing montants/dates, détection CSV, codec de format d'import, dictionnaire lexical, signatures de banques, charts, drapeaux SQL 0/1, liste et parcours de démarrage)
│ ├── i18n/ # Config i18next + locales FR/EN │ ├── i18n/ # Config i18next + locales FR/EN
│ ├── __fixtures__/ # csv/ : corpus de détection (15 cas figés) ; samples/ : gabarits du dossier d'exemple embarqués par Rust │ ├── __fixtures__/ # csv/ : corpus de détection (15 cas figés) ; samples/ : gabarits du dossier d'exemple embarqués par Rust
@ -406,9 +406,11 @@ Depuis le chantier #297-#301, l'accès aux modules est gaté par l'édition de l
| Règle du CTA | `src/shared/upsellCta.ts` | `upsellCtaFor(tier)` → `{ purchasable, noteKey }` : Base achetable, Premium désactivé avec `upsell.premiumNotForSale` ; testée sur toute la matrice (#346) | | Règle du CTA | `src/shared/upsellCta.ts` | `upsellCtaFor(tier)` → `{ purchasable, noteKey }` : Base achetable, Premium désactivé avec `upsell.premiumNotForSale` ; testée sur toute la matrice (#346) |
| Cadenas navigation | `Sidebar.tsx` (`NavLock`) + `ReportsPage` (tuiles du hub) | Badge verrou affiché si `ready && !allowed` ; les items restent **cliquables** (la route montre l'upsell — verrouillé visible, jamais masqué). `NAV_ITEMS[].feature?` porté par `budget` / `adjustments` / `balance` uniquement (invariant testé : `reports` n'est jamais gaté, le hub est Free) | | Cadenas navigation | `Sidebar.tsx` (`NavLock`) + `ReportsPage` (tuiles du hub) | Badge verrou affiché si `ready && !allowed` ; les items restent **cliquables** (la route montre l'upsell — verrouillé visible, jamais masqué). `NAV_ITEMS[].feature?` porté par `budget` / `adjustments` / `balance` uniquement (invariant testé : `reports` n'est jamais gaté, le hub est Free) |
| Gate multi-profils | `src/shared/profileGate.ts` | Prédicats purs `isProfileSwitchLocked` / `isProfileCreationLocked` (`multi-profile`, Base+) : le profil **actif** n'est jamais verrouillé, la création est gatée au point unique `ProfileFormModal` (mode upsell, qui réutilise `UpsellGate`) ; `ProfileSelectionPage` marque l'entrée « Créer » sans verrouiller les tuiles. Non destructif : aucune donnée supprimée, un upgrade fait tout réapparaître | | Gate multi-profils | `src/shared/profileGate.ts` | Prédicats purs `isProfileSwitchLocked` / `isProfileCreationLocked` (`multi-profile`, Base+) : le profil **actif** n'est jamais verrouillé, la création est gatée au point unique `ProfileFormModal` (mode upsell, qui réutilise `UpsellGate`) ; `ProfileSelectionPage` marque l'entrée « Créer » sans verrouiller les tuiles. Non destructif : aucune donnée supprimée, un upgrade fait tout réapparaître |
| Gate de la Répartition | `src/shared/splitGate.ts`, `src/components/transactions/SplitButton.tsx` | `splitAccessFor(gate)` → `open` / `locked` / `pending` sur `useEntitlement("adjustments")`, lu par `TransactionsPage` et passé à `TransactionTable` en prop obligatoire (#369). `locked` : cadenas sur le bouton et `UpsellDialog` au lieu de la fenêtre de répartition, pour toute ligne ; `pending` (licence pas `ready`, chargement ou erreur) : ni cadenas ni fenêtre, bouton inactif, `allowed` ne court-circuitant pas `ready`. La fenêtre de répartition n'est rendue que hors `locked`, l'incitation que hors `open`. Non destructif : les répartitions existantes restent comptées et gardent leur icône orange |
| `UpsellDialog` | `src/components/shared/UpsellDialog.tsx` | `UpsellGate` dans une fenêtre (`role="dialog"`, `aria-modal`, nom accessible `upsell.title`), pour une action gatée dans une page Free ; `onNavigate` ferme la fenêtre après « J'ai déjà une clé ». Même balisage que la fenêtre d'incitation de `ProfileSwitcher`, pas encore migrée dessus |
| Rust | `src-tauri/src/commands/entitlements.rs` | `FEATURE_TIERS` réduit à `auto-update` → Base+ (la matrice UI vit en TS) ; `check_entitlement` = matrice OU `features[]` signés avec court-circuit Free (même CWE-863), entitlements résolus par `license_commands::current_entitlements` | | Rust | `src-tauri/src/commands/entitlements.rs` | `FEATURE_TIERS` réduit à `auto-update` → Base+ (la matrice UI vit en TS) ; `check_entitlement` = matrice OU `features[]` signés avec court-circuit Free (même CWE-863), entitlements résolus par `license_commands::current_entitlements` |
Modules Free — jamais gatés, aucune `FeatureKey` : Dashboard, Import, Transactions, Catégories, hub `/reports` + `/reports/trends`, Export/Import, Paramètres, Changelog, docs. Le déclassement d'édition est **non destructif** : les données des modules verrouillés restent en base et redeviennent accessibles avec une clé valide. Modules Free — jamais gatés, aucune `FeatureKey` : Dashboard, Import, Transactions, Catégories, hub `/reports` + `/reports/trends`, Export/Import, Paramètres, Changelog, docs. Seule exception à l'intérieur d'une page Free : le bouton Répartition de la page Transactions, gaté par `adjustments` (#369). Le déclassement d'édition est **non destructif** : les données des modules verrouillés restent en base et redeviennent accessibles avec une clé valide.
## Commandes Tauri (39) ## Commandes Tauri (39)
@ -517,7 +519,7 @@ Ordre d'initialisation dans `lib.rs` (certains plugins ont des contraintes d'ord
| `tauri-plugin-dialog` | Dialogues de sélection de fichier/dossier | — | | `tauri-plugin-dialog` | Dialogues de sélection de fichier/dossier | — |
| `tauri-plugin-process` | Relaunch après mise à jour | — | | `tauri-plugin-process` | Relaunch après mise à jour | — |
| `tauri-plugin-deep-link` | Gère le scheme custom `simpl-resultat://` | Doit être initialisé avant `setup()` pour que `on_open_url` soit disponible | | `tauri-plugin-deep-link` | Gère le scheme custom `simpl-resultat://` | Doit être initialisé avant `setup()` pour que `on_open_url` soit disponible |
| `tauri-plugin-updater` | Mise à jour auto (gated par entitlement `auto-update`) | Initialisé dans `setup()` derrière `#[cfg(desktop)]` | | `tauri-plugin-updater` | Mise à jour auto (gated par entitlement `auto-update`) | Initialisé dans `setup()` derrière `#[cfg(desktop)]` ; compilé sans ses features par défaut (`rustls-tls` seule, #455) : aucune extraction d'archive sous Windows et Linux |
| `tauri-plugin-sql` | SQLite + migrations | Doit être initialisé avec les migrations pour que le schéma soit prêt | | `tauri-plugin-sql` | SQLite + migrations | Doit être initialisé avec les migrations pour que le schéma soit prêt |
## OAuth2 et deep-link (Compte Maximus) ## OAuth2 et deep-link (Compte Maximus)
@ -645,7 +647,7 @@ Quatre workflows Forgejo Actions dans `.forgejo/workflows/`. Le runner est à **
Déclenché sur les PR qui touchent `src-tauri/**`, `.cargo/**` (ou le workflow lui-même), chaque lundi à 06:30 UTC sur `main` et par `workflow_dispatch`. Lance `cargo check` puis `cargo test`, et un `cargo audit` informatif (non bloquant, binaire pré-buildé via `taiki-e/install-action`). Déclenché sur les PR qui touchent `src-tauri/**`, `.cargo/**` (ou le workflow lui-même), chaque lundi à 06:30 UTC sur `main` et par `workflow_dispatch`. Lance `cargo check` puis `cargo test`, et un `cargo audit` informatif (non bloquant, binaire pré-buildé via `taiki-e/install-action`).
Entre `cargo check` et `cargo test`, une étape **bloquante** rejoue la justification des advisories acceptées dans `.cargo/audit.toml` : elle vérifie par `cargo tree --locked` que chaque crate supprimé reste absent des deux cibles livrées, et échoue si l'un d'eux redevient atteignable. Un canari (`tar`, réellement présent) garantit que son silence prouve quelque chose. Voir [ADR 0018](adr/0018-suppression-advisories-non-atteignables.md). Entre `cargo check` et `cargo test`, une étape **bloquante** rejoue la justification des advisories acceptées dans `.cargo/audit.toml` : elle vérifie par `cargo tree --locked` que chaque crate supprimé reste absent des deux cibles livrées, et échoue si l'un d'eux redevient atteignable. Un canari (`webkit2gtk`, la webview Linux de Tauri, présente sous Linux et absente sous Windows ; `tar` jusqu'à #455) garantit que son silence prouve quelque chose, et que `--target` résout bien les dépendances conditionnelles. Voir [ADR 0018](adr/0018-suppression-advisories-non-atteignables.md).
Juste après, une seconde étape **bloquante**, `Verify windows-sys pinning` ([#345](https://git.lacompagniemaximus.com/maximus/simpl-resultat/issues/345)), lit `src-tauri/Cargo.lock` avec `awk`. Les sept crates qui déclarent `windows-sys` par une borne couvrant plusieurs mineures (`colored`, `dirs-sys`, `errno`, `rustix`, `rustls-platform-verifier`, `tempfile`, `winapi-util`) doivent tous pointer `0.61.2`, l'état de chaque release Windows depuis v0.14.0, et l'ensemble des versions de `windows-sys` du lock ne doit pas changer. Une mise à jour comme `cargo update -p hyper` re-résout ces arêtes sans que cargo l'annonce, et le build Windows compile alors cinq versions de `windows-sys` au lieu de trois. Chaque vérification est loggée avec un bilan, un canari (`tempfile`) doit être lu et vérifié, et une panne d'`awk` fait échouer l'étape. Quand elle rougit, on remet les arêtes à la main : procédure et condition de sortie dans l'[ADR 0023](adr/0023-windows-sys-pinning.md). Juste après, une seconde étape **bloquante**, `Verify windows-sys pinning` ([#345](https://git.lacompagniemaximus.com/maximus/simpl-resultat/issues/345)), lit `src-tauri/Cargo.lock` avec `awk`. Les sept crates qui déclarent `windows-sys` par une borne couvrant plusieurs mineures (`colored`, `dirs-sys`, `errno`, `rustix`, `rustls-platform-verifier`, `tempfile`, `winapi-util`) doivent tous pointer `0.61.2`, l'état de chaque release Windows depuis v0.14.0, et l'ensemble des versions de `windows-sys` du lock ne doit pas changer. Une mise à jour comme `cargo update -p hyper` re-résout ces arêtes sans que cargo l'annonce, et le build Windows compile alors cinq versions de `windows-sys` au lieu de trois. Chaque vérification est loggée avec un bilan, un canari (`tempfile`) doit être lu et vérifié, et une panne d'`awk` fait échouer l'étape. Quand elle rougit, on remet les arêtes à la main : procédure et condition de sortie dans l'[ADR 0023](adr/0023-windows-sys-pinning.md).
@ -660,7 +662,7 @@ Aucun `branches:` : une PR stackée sur une autre branche de feature — ce que
### `check-frontend.yml` — Vérifications frontend sur PR ### `check-frontend.yml` — Vérifications frontend sur PR
Déclenché sur les PR, sauf si tous les fichiers modifiés sont du Rust, de la doc ou du markdown (`paths-ignore`). Lance `npm ci`, `npm run build` (tsc + vite) et `npm test` (vitest). Installe aussi `jq`, dont a besoin `scripts/build-latest-json.test.mjs` : ce test lance le script de génération de `latest.json` de `release.yml` ([#320](https://git.lacompagniemaximus.com/maximus/simpl-resultat/issues/320)). Pas de cache npm : ce workflow ne tourne que sur des PR, donc un cache ne servirait qu'aux pushes suivants d'une même PR, et `npm ci` y prend 9 s. Déclenché sur les PR, sauf si tous les fichiers modifiés sont du Rust, de la doc, du markdown, `check-rust.yml` ou `audit.yml` (`paths-ignore`). `.claude/**` et `release.yml` le déclenchent ([#419](https://git.lacompagniemaximus.com/maximus/simpl-resultat/issues/419)) : `scripts/build-latest-json.test.mjs` lit la liste de clés de `verify-release.sh` et exécute le texte de l'étape « Publish latest.json to package registry ». Lance `npm ci`, `npm run build` (tsc + vite) et `npm test` (vitest). Installe aussi `jq`, dont a besoin `scripts/build-latest-json.test.mjs` : ce test lance le script de génération de `latest.json` de `release.yml` ([#320](https://git.lacompagniemaximus.com/maximus/simpl-resultat/issues/320)). Pas de cache npm : ce workflow ne tourne que sur des PR, donc un cache ne servirait qu'aux pushes suivants d'une même PR, et `npm ci` y prend 9 s.
**Aucune étape `npm audit`** — contrairement au Rust, les advisories npm ne sont donc pas un gate de CI. `npm audit` est à 0 au 2026-10-10 ([#358](https://git.lacompagniemaximus.com/maximus/simpl-resultat/issues/358)), après un rafraîchissement du lock dans les bornes de `package.json`. Ce 0 est un constat daté, pas un état garanti : aucun job ne le fait échouer, mais l'étape `npm ci` de `check-frontend` audite à chaque run et en imprime le compte (`found 0 vulnerabilities`, ou le nombre trouvé), et le Défenseur relève les advisories de son côté (#358 en vient). `npm audit` n'a pas de mécanisme d'exclusion natif, il n'y a donc pas d'équivalent du `.cargo/audit.toml` côté npm. **Aucune étape `npm audit`** — contrairement au Rust, les advisories npm ne sont donc pas un gate de CI. `npm audit` est à 0 au 2026-10-10 ([#358](https://git.lacompagniemaximus.com/maximus/simpl-resultat/issues/358)), après un rafraîchissement du lock dans les bornes de `package.json`. Ce 0 est un constat daté, pas un état garanti : aucun job ne le fait échouer, mais l'étape `npm ci` de `check-frontend` audite à chaque run et en imprime le compte (`found 0 vulnerabilities`, ou le nombre trouvé), et le Défenseur relève les advisories de son côté (#358 en vient). `npm audit` n'a pas de mécanisme d'exclusion natif, il n'y a donc pas d'équivalent du `.cargo/audit.toml` côté npm.
@ -683,7 +685,7 @@ Déclenché par les tags `v*`. Un seul job, `build-and-release`, dans un contene
Fonctionnalités : Fonctionnalités :
- Signature des binaires (clés TAURI_SIGNING_PRIVATE_KEY) - Signature des binaires (clés TAURI_SIGNING_PRIVATE_KEY)
- JSON d'updater (`latest.json`) écrit par `scripts/build-latest-json.sh` à partir des assets ([#320](https://git.lacompagniemaximus.com/maximus/simpl-resultat/issues/320)). `tauri-plugin-updater` cherche `{os}-{arch}-{installer}`, puis `{os}-{arch}` : le fichier porte `linux-x86_64-deb` et `linux-x86_64-rpm` (une clé par paquet Linux) et `windows-x86_64` (l'installeur NSIS). Pas de clé générique `linux-x86_64` depuis le 2026-10-11 : elle servait le `.deb` à des installations qui ne savaient pas l'installer (anciennes AppImage, binaire hors paquet), qui reçoivent désormais `TargetsNotFound` à la vérification (raisons et lecteurs dans `docs/qa-update-cycle.md`). Le script échoue s'il ne trouve pas exactement un `.deb`, un `.rpm` et un `-setup.exe`, chacun avec un `.sig` non vide ; le JSON vient de `jq` seul (notes lues comme données par `--rawfile`), avec les noms de fichiers encodés par `@uri`, comme à l'envoi des assets. Testé par `scripts/build-latest-json.test.mjs` (vitest, lancé par `check-frontend`), qui l'exécute sous `bash -e -o pipefail` comme `release.yml`. - JSON d'updater (`latest.json`) écrit par `scripts/build-latest-json.sh` à partir des assets ([#320](https://git.lacompagniemaximus.com/maximus/simpl-resultat/issues/320)). `tauri-plugin-updater` cherche `{os}-{arch}-{installer}`, puis `{os}-{arch}` : le fichier porte `linux-x86_64-deb` et `linux-x86_64-rpm` (une clé par paquet Linux) et `windows-x86_64` (l'installeur NSIS). Pas de clé générique `linux-x86_64` depuis le 2026-10-11 : elle servait le `.deb` à des installations qui ne savaient pas l'installer (anciennes AppImage, binaire hors paquet), qui reçoivent désormais `TargetsNotFound` à la vérification (raisons et lecteurs dans `docs/qa-update-cycle.md`). Le script échoue s'il ne trouve pas exactement un `.deb`, un `.rpm` et un `-setup.exe`, chacun avec un `.sig` non vide ; le JSON vient de `jq` seul (notes lues comme données par `--rawfile`), avec les noms de fichiers encodés par `@uri`, comme à l'envoi des assets. Testé par `scripts/build-latest-json.test.mjs` (vitest, lancé par `check-frontend`), qui l'exécute sous `bash -e -o pipefail` comme `release.yml`.
- JSON publié sur `https://git.lacompagniemaximus.com/api/packages/maximus/generic/simpl-resultat/latest/latest.json` (registre de paquets, endpoint de `tauri.conf.json`). Avant le `DELETE` de l'ancienne version, le filtre `scripts/check-latest-json.jq` (version égale au tag, exactement les trois clés, signatures non vides, extension de chaque URL) arrête le job si le fichier ne le passe pas : le registre reste intact. Le test du script applique ce même filtre à chaque PR et compare sa liste de clés à celles du script et de `verify-release.sh`, si bien qu'un écart se voit avant le tag. - JSON publié sur `https://git.lacompagniemaximus.com/api/packages/maximus/generic/simpl-resultat/latest/latest.json` (registre de paquets, endpoint de `tauri.conf.json`). Avant le `DELETE` de l'ancienne version, le filtre `scripts/check-latest-json.jq` (version égale au tag, exactement les trois clés, signatures non vides, extension de chaque URL) arrête le job si le fichier ne le passe pas : le registre reste intact. Il est appelé avec `-s` et n'accepte qu'un seul document, si bien qu'un fichier vide ou à deux documents est refusé sous jq 1.6 comme sous 1.7 ([#419](https://git.lacompagniemaximus.com/maximus/simpl-resultat/issues/419)). Le test du script applique ce même filtre à chaque PR, compare sa liste de clés à celles du script et de `verify-release.sh`, et exécute le texte de l'étape « Publish » avec un `curl` factice (`DELETE` puis `PUT` sur un manifeste valide, aucun appel sur un manifeste refusé), si bien qu'un écart se voit avant le tag.
- Release Forgejo automatique avec assets et release notes extraites du CHANGELOG.md, transmises par `GITHUB_OUTPUT` sous un délimiteur aléatoire (une ligne du CHANGELOG égale à un délimiteur fixe terminait la valeur) - Release Forgejo automatique avec assets et release notes extraites du CHANGELOG.md, transmises par `GITHUB_OUTPUT` sous un délimiteur aléatoire (une ligne du CHANGELOG égale à un délimiteur fixe terminait la valeur)
**Node.js 24** ([#376](https://git.lacompagniemaximus.com/maximus/simpl-resultat/issues/376)), installé comme dans les trois autres workflows par le script NodeSource (`curl … | bash`). Ce script tourne en root dans le conteneur du job, et le Node.js qu'il installe lance ensuite `npx tauri build` avec `TAURI_SIGNING_PRIVATE_KEY` dans son environnement : le job confie à NodeSource (script et dépôt apt) la clé de signature de l'updater, et les jetons de publication des étapes suivantes. Risque accepté par Max le 2026-10-10, alternative écartée : un tarball Node.js épinglé. La chaîne Rust, installée de la même façon par `rustup`, est dans le même cas. Ce workflow ne tourne qu'au tag : la première release après #376 est la première sous Node 24, à vérifier par `node --version` dans le journal du run (étape 9 de `/release`). **Node.js 24** ([#376](https://git.lacompagniemaximus.com/maximus/simpl-resultat/issues/376)), installé comme dans les trois autres workflows par le script NodeSource (`curl … | bash`). Ce script tourne en root dans le conteneur du job, et le Node.js qu'il installe lance ensuite `npx tauri build` avec `TAURI_SIGNING_PRIVATE_KEY` dans son environnement : le job confie à NodeSource (script et dépôt apt) la clé de signature de l'updater, et les jetons de publication des étapes suivantes. Risque accepté par Max le 2026-10-10, alternative écartée : un tarball Node.js épinglé. La chaîne Rust, installée de la même façon par `rustup`, est dans le même cas. Ce workflow ne tourne qu'au tag : la première release après #376 est la première sous Node 24, à vérifier par `node --version` dans le journal du run (étape 9 de `/release`).

View file

@ -241,7 +241,7 @@ Parcourez, filtrez, triez et catégorisez toutes vos transactions importées. C'
- Assignation de catégorie en ligne via menu déroulant - Assignation de catégorie en ligne via menu déroulant
- Auto-catégorisation basée sur les règles de mots-clés - Auto-catégorisation basée sur les règles de mots-clés
- Ajout de mots-clés directement depuis une transaction - Ajout de mots-clés directement depuis une transaction
- Répartition (split) d'une transaction sur plusieurs catégories - Répartition (split) d'une transaction sur plusieurs catégories (édition Base et plus)
- Notes sur les transactions - Notes sur les transactions
### Comment faire ### Comment faire
@ -251,14 +251,14 @@ Parcourez, filtrez, triez et catégorisez toutes vos transactions importées. C'
3. Pour catégoriser une transaction, cliquez sur son menu déroulant de catégorie et sélectionnez une catégorie 3. Pour catégoriser une transaction, cliquez sur son menu déroulant de catégorie et sélectionnez une catégorie
4. Pour auto-catégoriser toutes les transactions non catégorisées, cliquez sur le bouton Auto-catégoriser 4. Pour auto-catégoriser toutes les transactions non catégorisées, cliquez sur le bouton Auto-catégoriser
5. Pour ajouter une règle de mot-clé depuis une transaction, faites un clic droit sur sa ligne, puis « Ajouter comme mot-clé » ; sur une transaction déjà classée, l'icône étiquette ajoute aussi un mot-clé à sa catégorie 5. Pour ajouter une règle de mot-clé depuis une transaction, faites un clic droit sur sa ligne, puis « Ajouter comme mot-clé » ; sur une transaction déjà classée, l'icône étiquette ajoute aussi un mot-clé à sa catégorie
6. Pour répartir une transaction sur plusieurs catégories, utilisez le bouton Répartition et ajoutez les montants par catégorie 6. Pour répartir une transaction sur plusieurs catégories, utilisez le bouton Répartition et ajoutez les montants par catégorie (édition Base et plus ; en édition Gratuite, le bouton porte un cadenas et ouvre l'écran de déverrouillage)
### Astuces ### Astuces
- Utilisez les boutons de période rapide (Ce mois, Mois dernier, etc.) pour filtrer rapidement par date - Utilisez les boutons de période rapide (Ce mois, Mois dernier, etc.) pour filtrer rapidement par date
- L'auto-catégorisation n'affecte que les transactions non catégorisées — elle n'écrase pas les assignations manuelles - L'auto-catégorisation n'affecte que les transactions non catégorisées — elle n'écrase pas les assignations manuelles
- Ajouter un mot-clé depuis une transaction pré-remplit la catégorie pour construire rapidement vos règles - Ajouter un mot-clé depuis une transaction pré-remplit la catégorie pour construire rapidement vos règles
- Les transactions réparties affichent un indicateur visuel et le détail de la répartition - Les transactions réparties affichent un indicateur visuel, quelle que soit l'édition ; le détail de la répartition s'ouvre par le bouton Répartition (édition Base et plus)
--- ---
@ -622,13 +622,14 @@ Simpl'Résultat existe en trois éditions : **Gratuite**, **Base** et **Premium*
|---|:---:|:---:|:---:| |---|:---:|:---:|:---:|
| Tableau de bord | ✓ | ✓ | ✓ | | Tableau de bord | ✓ | ✓ | ✓ |
| Import CSV | ✓ | ✓ | ✓ | | Import CSV | ✓ | ✓ | ✓ |
| Transactions | ✓ | ✓ | ✓ | | Transactions (sans la Répartition) | ✓ | ✓ | ✓ |
| Catégories | ✓ | ✓ | ✓ | | Catégories | ✓ | ✓ | ✓ |
| Rapports — hub et Tendances | ✓ | ✓ | ✓ | | Rapports — hub et Tendances | ✓ | ✓ | ✓ |
| Export / import chiffré | ✓ | ✓ | ✓ | | Export / import chiffré | ✓ | ✓ | ✓ |
| Journal des modifications | ✓ | ✓ | ✓ | | Journal des modifications | ✓ | ✓ | ✓ |
| Profils | 1 profil | ✓ multiples | ✓ multiples | | Profils | 1 profil | ✓ multiples | ✓ multiples |
| Ajustements | — | ✓ | ✓ | | Ajustements | — | ✓ | ✓ |
| Répartition des transactions | — | ✓ | ✓ |
| Budget | — | ✓ | ✓ | | Budget | — | ✓ | ✓ |
| Rapports avancés (Faits saillants, Comparables, Analyse par catégorie, Cartes) | — | ✓ | ✓ | | Rapports avancés (Faits saillants, Comparables, Analyse par catégorie, Cartes) | — | ✓ | ✓ |
| Mises à jour automatiques | — | ✓ | ✓ | | Mises à jour automatiques | — | ✓ | ✓ |
@ -636,14 +637,14 @@ Simpl'Résultat existe en trois éditions : **Gratuite**, **Base** et **Premium*
### Comment ça se présente ### Comment ça se présente
Les modules au-dessus de votre édition restent **visibles mais verrouillés** : un cadenas apparaît dans la barre latérale (Budget, Ajustements, Bilan) et sur les tuiles de rapports avancés du hub Rapports. Les ouvrir affiche un écran de déverrouillage qui indique l'édition requise, avec deux actions : Les modules au-dessus de votre édition restent **visibles mais verrouillés** : un cadenas apparaît dans la barre latérale (Budget, Ajustements, Bilan), sur les tuiles de rapports avancés du hub Rapports et sur le bouton Répartition de la page Transactions. Les ouvrir affiche un écran de déverrouillage qui indique l'édition requise, avec deux actions :
- **Obtenir Base** — ouvre la page produit dans votre navigateur pour acheter l'édition Base. Sur l'écran du Bilan, réservé à Premium, le bouton s'intitule « Obtenir Premium » et reste désactivé : Premium n'est pas encore offert en ligne - **Obtenir Base** — ouvre la page produit dans votre navigateur pour acheter l'édition Base. Sur l'écran du Bilan, réservé à Premium, le bouton s'intitule « Obtenir Premium » et reste désactivé : Premium n'est pas encore offert en ligne
- **J'ai déjà une clé** — mène directement à la carte de licence (Paramètres → Utilisateurs) pour entrer votre clé - **J'ai déjà une clé** — mène directement à la carte de licence (Paramètres → Utilisateurs) pour entrer votre clé
### Comment faire ### Comment faire
1. Repérez le cadenas dans la barre latérale ou sur les tuiles du hub Rapports : il marque les modules au-dessus de votre édition 1. Repérez le cadenas dans la barre latérale, sur les tuiles du hub Rapports ou sur le bouton Répartition de la page Transactions : il marque ce qui est au-dessus de votre édition
2. Cliquez sur un module verrouillé pour voir l'édition requise 2. Cliquez sur un module verrouillé pour voir l'édition requise
3. Pour acheter l'édition Base, cliquez sur « Obtenir Base » : la page produit s'ouvre dans votre navigateur (Premium n'est pas encore offert en ligne) 3. Pour acheter l'édition Base, cliquez sur « Obtenir Base » : la page produit s'ouvre dans votre navigateur (Premium n'est pas encore offert en ligne)
4. Une fois votre clé de licence en main, cliquez sur « J'ai déjà une clé » (ou allez dans Paramètres → Utilisateurs) et entrez-la 4. Une fois votre clé de licence en main, cliquez sur « J'ai déjà une clé » (ou allez dans Paramètres → Utilisateurs) et entrez-la
@ -666,5 +667,6 @@ La carte Licence (Paramètres → Utilisateurs) réunit votre clé et vos machin
- Le verrouillage n'est **jamais destructif** : si votre édition baisse (clé expirée, invalide, révoquée ou retirée), les données des modules verrouillés — budgets, ajustements, snapshots de bilan, profils — sont intégralement conservées et réapparaissent dès qu'une clé valide est entrée - Le verrouillage n'est **jamais destructif** : si votre édition baisse (clé expirée, invalide, révoquée ou retirée), les données des modules verrouillés — budgets, ajustements, snapshots de bilan, profils — sont intégralement conservées et réapparaissent dès qu'une clé valide est entrée
- En édition Gratuite, votre **profil actif reste toujours accessible** — seuls la création d'un profil supplémentaire et le passage à un autre profil sont verrouillés - En édition Gratuite, votre **profil actif reste toujours accessible** — seuls la création d'un profil supplémentaire et le passage à un autre profil sont verrouillés
- En édition Gratuite, les **répartitions déjà faites restent dans vos données** : elles sont toujours comptées dans les rapports et le tableau de bord, et toujours signalées dans la liste des transactions — seuls l'ouverture et la modification d'une répartition sont verrouillées
- La clé de licence s'applique à toute la machine, pas à un profil : elle déverrouille les modules pour tous les profils du poste - La clé de licence s'applique à toute la machine, pas à un profil : elle déverrouille les modules pour tous les profils du poste
- En édition Gratuite, les mises à jour automatiques sont verrouillées : la carte Mises à jour (Paramètres → Système) propose de télécharger la dernière version depuis la page produit, et l'installation manuelle reste gratuite - En édition Gratuite, les mises à jour automatiques sont verrouillées : la carte Mises à jour (Paramètres → Système) propose de télécharger la dernière version depuis la page produit, et l'installation manuelle reste gratuite

View file

@ -1,6 +1,6 @@
# QA — Cycle de mise à jour automatique # QA — Cycle de mise à jour automatique
Checklist manuelle pour valider qu'une release est réellement installable **par la mise à jour automatique**, et pas seulement téléchargeable. À dérouler après chaque release qui touche `tauri-plugin-updater`, ses dépendances (`reqwest`/`rustls-webpki`, `tar`), `release.yml`, ou la configuration `updater` de `tauri.conf.json`. Checklist manuelle pour valider qu'une release est réellement installable **par la mise à jour automatique**, et pas seulement téléchargeable. À dérouler après chaque release qui touche `tauri-plugin-updater`, ses dépendances (`reqwest`/`rustls`/`rustls-webpki`), `release.yml`, ou la configuration `updater` de `tauri.conf.json`.
Elle existe parce que la CI ne peut rien prouver ici : `cargo check` et `cargo test` prouvent que le chemin de mise à jour **compile**, jamais qu'il fonctionne. Une régression s'y manifeste chez l'utilisateur, au moment de la mise à jour — et la mise à jour automatique étant une fonctionnalité **Base+**, ce sont des utilisateurs payants. Elle existe parce que la CI ne peut rien prouver ici : `cargo check` et `cargo test` prouvent que le chemin de mise à jour **compile**, jamais qu'il fonctionne. Une régression s'y manifeste chez l'utilisateur, au moment de la mise à jour — et la mise à jour automatique étant une fonctionnalité **Base+**, ce sont des utilisateurs payants.
@ -29,7 +29,7 @@ Ce sont les conditions sans lesquelles le test ne teste rien. Chacune a un mode
**Prouvé** sur chaque cible déroulée : le téléchargement TLS (donc `rustls-webpki`), la vérification de signature minisign, l'exécution de l'installeur, et le redémarrage sur la nouvelle version. **Prouvé** sur chaque cible déroulée : le téléchargement TLS (donc `rustls-webpki`), la vérification de signature minisign, l'exécution de l'installeur, et le redémarrage sur la nouvelle version.
**Non prouvé** : l'extraction d'archive, donc `tar`. Dans `tauri-plugin-updater`, `tar` n'est atteint que par `install_appimage` et la branche macOS `.app.tar.gz`. Aucun de nos bundles ne passe par là — le `.deb` fait `pkexec dpkg -i`, le NSIS écrit le `.exe` dans un répertoire temporaire et le lance. **Tant qu'on ne livre pas d'AppImage, le chemin `tar` reste mort** et aucune checklist manuelle ne le couvrira. **Non prouvé** : l'extraction d'archive, et pour cause : depuis #455, `tauri-plugin-updater` est compilé sans sa feature `zip`, donc sans `zip` sous Windows ni `tar` sous Linux. Avec `createUpdaterArtifacts: true`, aucun de nos paquets de mise à jour n'est une archive : le NSIS écrit le `.exe` dans un répertoire temporaire et le lance, le `.deb` passe par `pkexec dpkg -i` et le `.rpm` par `pkexec rpm -U` (§3) ; une AppImage brute, si on en livrait une, serait réécrite en place par la branche « rewriting AppImage », qui ne dépend pas de la feature. Seul `createUpdaterArtifacts: "v1Compatible"` produit des archives (`.nsis.zip`, `.msi.zip`, `.AppImage.tar.gz`). **Passer à ce mode exige de rétablir la feature** (`src-tauri/Cargo.toml`) : sans elle, un zip échoue sous Windows (`InvalidUpdaterFormat`, rien n'est installé) et un `.tar.gz` serait écrit par-dessus l'AppImage sous Linux. La branche macOS `.app.tar.gz` garde `tar`, mais l'app n'est pas livrée sur macOS.
--- ---
@ -93,7 +93,7 @@ Les installations `.rpm` existantes n'ont rien à réinstaller : le binaire du `
**Pas de clé générique `linux-x86_64`** (retirée le 2026-10-11, décision de Max sur #320). Toutes les versions qui embarquent l'updater (v0.2.1 et suivantes) verrouillent `tauri-plugin-updater` 2.10.0 et `tauri-utils` 2.8.2, et leurs paquets portent le marqueur de type (vérifié dans le `.deb` et le `.rpm` de la v0.17.1) : une installation `.deb` ou `.rpm` trouve sa clé avant de chercher `linux-x86_64`. Cette clé servait le `.deb`, et ses derniers lecteurs ne savaient pas l'installer : **Pas de clé générique `linux-x86_64`** (retirée le 2026-10-11, décision de Max sur #320). Toutes les versions qui embarquent l'updater (v0.2.1 et suivantes) verrouillent `tauri-plugin-updater` 2.10.0 et `tauri-utils` 2.8.2, et leurs paquets portent le marqueur de type (vérifié dans le `.deb` et le `.rpm` de la v0.17.1) : une installation `.deb` ou `.rpm` trouve sa clé avant de chercher `linux-x86_64`. Cette clé servait le `.deb`, et ses derniers lecteurs ne savaient pas l'installer :
- les AppImage des v0.4.3 à v0.4.5, publiées sur Forgejo pour la v0.4.3 et sur le miroir GitHub pour les v0.4.3 à v0.4.5. Leur updater lit le registre et cherche `linux-x86_64-appimage`, absente, puis `linux-x86_64`. La v0.4.2 vise `…/releases/latest/download/latest.json`, qui répond 404 sur cette instance Forgejo, et les versions antérieures lisent GitHub ; - les AppImage des v0.4.3 à v0.4.5, publiées sur Forgejo pour la v0.4.3 et sur le miroir GitHub pour les v0.4.3 à v0.4.5. Leur updater lit le registre et cherche `linux-x86_64-appimage`, absente, puis `linux-x86_64`. La v0.4.2 vise `…/releases/latest/download/latest.json`, qui répond 404 sur cette instance Forgejo, et les versions antérieures lisent GitHub ;
- un binaire lancé hors de son paquet, sans marqueur, qui ne cherche que `linux-x86_64`. - un binaire lancé hors de son paquet, sans marqueur, qui ne cherche que `linux-x86_64`. C'est le cas d'un build `npm run tauri dev` sous Linux : sa carte Mises à jour affiche l'erreur `TargetsNotFound` à chaque vérification. Elle est attendue depuis le retrait de la clé, ce n'est pas une régression (#419).
Avec la clé, l'updater les traitait en AppImage (`install_appimage`) et écrivait les octets du `.deb` à la place du fichier lancé, ce qui cassait l'installation. Sans elle, leur vérification échoue avec `TargetsNotFound` et rien n'est installé : la carte Mises à jour affiche une erreur. La voie, pour ces installations, est de télécharger le `.deb` ou le `.rpm` de la dernière release. Avec la clé, l'updater les traitait en AppImage (`install_appimage`) et écrivait les octets du `.deb` à la place du fichier lancé, ce qui cassait l'installation. Sans elle, leur vérification échoue avec `TargetsNotFound` et rien n'est installé : la carte Mises à jour affiche une erreur. La voie, pour ces installations, est de télécharger le `.deb` ou le `.rpm` de la dernière release.
@ -105,7 +105,7 @@ L'ordre compte, et la première action ne répare pas ce qu'on croit.
1. **Republier le `latest.json` précédent** sur `generic/simpl-resultat/latest` (`DELETE` puis `PUT`, avec le `PACKAGE_TOKEN` qu'utilise `release.yml`). Le corps est récupérable depuis les assets de la release précédente — sa copie dans le registre a été détruite par le `DELETE` que `release.yml` fait avant chaque envoi. 1. **Republier le `latest.json` précédent** sur `generic/simpl-resultat/latest` (`DELETE` puis `PUT`, avec le `PACKAGE_TOKEN` qu'utilise `release.yml`). Le corps est récupérable depuis les assets de la release précédente — sa copie dans le registre a été détruite par le `DELETE` que `release.yml` fait avant chaque envoi.
**Les deux verbes ne visent pas le même préfixe d'API**, et `release.yml:217-219` porte un commentaire explicite là-dessus — le piège est déjà tombé une fois : **Les deux verbes ne visent pas le même préfixe d'API**, et l'étape « Publish latest.json to package registry » de `release.yml` porte un commentaire explicite là-dessus — le piège est déjà tombé une fois :
``` ```
DELETE {serveur}/api/v1/packages/{owner}/generic/simpl-resultat/latest DELETE {serveur}/api/v1/packages/{owner}/generic/simpl-resultat/latest

View file

@ -18,7 +18,13 @@
* It also runs scripts/check-latest-json.jq, the filter release.yml applies * It also runs scripts/check-latest-json.jq, the filter release.yml applies
* before deleting the published manifest, so that check is exercised on every * before deleting the published manifest, so that check is exercised on every
* pull request instead of only at the tag, and compares the key lists of the * pull request instead of only at the tag, and compares the key lists of the
* script, of that filter and of verify-release.sh. * script, of that filter and of verify-release.sh. Last, it runs the text of
* release.yml's "Publish latest.json to package registry" step against a fake
* curl, so the way the step calls the filter is tested too (#419).
*
* check-frontend.yml does not ignore .claude/** nor release.yml for that
* reason: a pull request that only touches verify-release.sh or release.yml
* still runs this file.
* *
* Plain .mjs on purpose: tsconfig only covers src/, so a .ts file here would * Plain .mjs on purpose: tsconfig only covers src/, so a .ts file here would
* look type-checked without being so. Needs bash and jq on PATH; * look type-checked without being so. Needs bash and jq on PATH;
@ -27,7 +33,7 @@
import { describe, it, expect, beforeAll, beforeEach, afterEach } from "vitest"; import { describe, it, expect, beforeAll, beforeEach, afterEach } from "vitest";
import { spawnSync } from "node:child_process"; import { spawnSync } from "node:child_process";
import { mkdtempSync, mkdirSync, readFileSync, rmSync, unlinkSync, writeFileSync } from "node:fs"; import { chmodSync, copyFileSync, existsSync, mkdtempSync, mkdirSync, readFileSync, rmSync, unlinkSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os"; import { tmpdir } from "node:os";
import { dirname, join } from "node:path"; import { dirname, join } from "node:path";
import { fileURLToPath } from "node:url"; import { fileURLToPath } from "node:url";
@ -36,6 +42,7 @@ const HERE = dirname(fileURLToPath(import.meta.url));
const SCRIPT = join(HERE, "build-latest-json.sh"); const SCRIPT = join(HERE, "build-latest-json.sh");
const CHECK = join(HERE, "check-latest-json.jq"); const CHECK = join(HERE, "check-latest-json.jq");
const VERIFY_RELEASE = join(HERE, "..", ".claude", "skills", "release", "scripts", "verify-release.sh"); const VERIFY_RELEASE = join(HERE, "..", ".claude", "skills", "release", "scripts", "verify-release.sh");
const RELEASE_YML = join(HERE, "..", ".forgejo", "workflows", "release.yml");
const BASE = "https://git.example.test/owner/Repo/releases/download/v0.17.1"; const BASE = "https://git.example.test/owner/Repo/releases/download/v0.17.1";
const DEB = "Simpl Resultat_0.17.1_amd64.deb"; const DEB = "Simpl Resultat_0.17.1_amd64.deb";
@ -80,12 +87,13 @@ function builtManifest() {
return JSON.parse(result.stdout); return JSON.parse(result.stdout);
} }
// Runs scripts/check-latest-json.jq as release.yml does, on a manifest written // Runs scripts/check-latest-json.jq with the options release.yml passes, on a
// to a file: an object is serialized, a string is written as is. // manifest written to a file: an object is serialized, a string is written as
// is. The "release.yml Publish step" tests below run the step's own text.
function check(manifest, version = "0.17.1") { function check(manifest, version = "0.17.1") {
const file = join(work, "latest.json"); const file = join(work, "latest.json");
writeFileSync(file, typeof manifest === "string" ? manifest : JSON.stringify(manifest)); writeFileSync(file, typeof manifest === "string" ? manifest : JSON.stringify(manifest));
return spawnSync("jq", ["-e", "--arg", "version", version, "-f", CHECK, file], { encoding: "utf8" }); return spawnSync("jq", ["-e", "-s", "--arg", "version", version, "-f", CHECK, file], { encoding: "utf8" });
} }
beforeAll(() => { beforeAll(() => {
@ -283,6 +291,21 @@ describe("check-latest-json.jq", () => {
it("refuses a file that is not JSON", () => { it("refuses a file that is not JSON", () => {
expect(check('{"version": "0.17.1", "platforms": ').status).not.toBe(0); expect(check('{"version": "0.17.1", "platforms": ').status).not.toBe(0);
}); });
// Without -s, jq 1.6 (the CI's and the release container's) produces no
// output for an empty file, which -e lets through with status 0 (#419).
it("refuses an empty file", () => {
expect(check("").status).not.toBe(0);
});
// Without -s, jq judges each document and its exit status follows the last
// one, under 1.6 and 1.7 alike: a valid last document hid a broken first one.
it("refuses a file with two documents, even when the last one is valid", () => {
const valid = JSON.stringify(builtManifest());
expect(check(`{"version": "0.0.0"}\n${valid}\n`).status).not.toBe(0);
expect(check(`${valid}\n${valid}\n`).status).not.toBe(0);
});
}); });
describe("key lists", () => { describe("key lists", () => {
@ -300,3 +323,140 @@ describe("key lists", () => {
expect(expected).toStrictEqual(written); expect(expected).toStrictEqual(written);
}); });
}); });
// The text of release.yml's "Publish latest.json to package registry" step,
// dedented. A whitespace-only line stays in the block; the block ends at the
// first non-blank line indented no deeper than "run:", or at the end of file.
function publishStepScript() {
const lines = readFileSync(RELEASE_YML, "utf8").split("\n");
const step = lines.findIndex((l) => /^\s*- name: Publish latest\.json to package registry\s*$/.test(l));
expect(step).toBeGreaterThan(-1);
const runAt = lines.findIndex((l, i) => i > step && /^\s*run: \|\s*$/.test(l));
expect(runAt).toBeGreaterThan(step);
const runIndent = lines[runAt].search(/\S/);
const block = [];
for (const line of lines.slice(runAt + 1)) {
if (line.trim() !== "" && line.search(/\S/) <= runIndent) break;
block.push(line);
}
while (block.length > 0 && block[block.length - 1].trim() === "") block.pop();
const indent = block[0].search(/\S/);
return block.map((l) => l.slice(indent)).join("\n") + "\n";
}
describe("release.yml Publish step", () => {
const SERVER = "https://git.example.test";
const DELETE_URL = `${SERVER}/api/v1/packages/owner/generic/simpl-resultat/latest`;
const PUT_URL = `${SERVER}/api/packages/owner/generic/simpl-resultat/latest/latest.json`;
let curlLog;
// A curl that records "METHOD URL" (and the uploaded file for a PUT) and
// prints the status code the step reads through -w: 204 to the DELETE, 201
// to the PUT. It never touches the network.
function writeFakeCurl(bin) {
const curl = join(bin, "curl");
writeFileSync(
curl,
`#!/usr/bin/env bash
method=GET url= data=
while [ $# -gt 0 ]; do
case "$1" in
-X) method="$2"; shift ;;
-H|-o|-w) shift ;;
--data-binary) data="$2"; shift ;;
-*) ;;
*) url="$1" ;;
esac
shift
done
if [ "$method" = PUT ]; then
[ -f "\${data#@}" ] || { echo "fake curl: no file \${data#@}" >&2; exit 2; }
echo "PUT $url \${data#@}" >> "$CURL_LOG"
printf 201
else
echo "$method $url" >> "$CURL_LOG"
printf 204
fi
`,
);
chmodSync(curl, 0o755);
}
// Runs the step's text as the runner's default "shell: bash" does, in a
// directory laid out like the checkout: scripts/check-latest-json.jq and
// release-assets/latest.json.
function publish(manifest, tag = "v0.17.1") {
const bin = join(work, "bin");
mkdirSync(bin);
writeFakeCurl(bin);
mkdirSync(join(work, "scripts"));
copyFileSync(CHECK, join(work, "scripts", "check-latest-json.jq"));
writeFileSync(
join(assets, "latest.json"),
typeof manifest === "string" ? manifest : JSON.stringify(manifest),
);
curlLog = join(work, "curl.log");
return spawnSync("bash", ["--noprofile", "--norc", "-e", "-o", "pipefail", "-c", publishStepScript()], {
cwd: work,
encoding: "utf8",
env: {
...process.env,
PATH: `${bin}:${process.env.PATH}`,
CURL_LOG: curlLog,
GITHUB_REF_NAME: tag,
GITHUB_SERVER_URL: SERVER,
GITHUB_REPOSITORY_OWNER: "owner",
FORGEJO_TOKEN: "fake-token",
},
});
}
function curlCalls() {
return existsSync(curlLog) ? readFileSync(curlLog, "utf8").trim().split("\n") : [];
}
it("is extracted whole, without workflow expressions", () => {
const script = publishStepScript();
expect(script).toContain("-f scripts/check-latest-json.jq");
expect(script).toContain("-X DELETE");
expect(script).toContain("-X PUT");
// The runner would substitute them before bash runs; this test would not.
expect(script).not.toContain("${{");
});
it("deletes the published manifest, then uploads the new one", () => {
const result = publish(builtManifest());
expect(result.status).toBe(0);
expect(curlCalls()).toStrictEqual([
`DELETE ${DELETE_URL}`,
`PUT ${PUT_URL} release-assets/latest.json`,
]);
});
it.each([
["the generic linux-x86_64 key", (m) => (m.platforms["linux-x86_64"] = m.platforms["linux-x86_64-deb"])],
["a version that is not the tag's", (m) => (m.version = "0.18.0")],
])("stops before any request on %s", (_, mutate) => {
const manifest = builtManifest();
mutate(manifest);
const result = publish(manifest);
expect(result.status).toBe(1);
expect(result.stdout).toContain("failed scripts/check-latest-json.jq, registry left as is");
expect(curlCalls()).toStrictEqual([]);
});
it.each([
["an empty file", () => ""],
["two documents, the last one valid", () => `{"version": "0.0.0"}\n${JSON.stringify(builtManifest())}\n`],
])("stops before any request on %s", (_, manifest) => {
const result = publish(manifest());
expect(result.status).toBe(1);
expect(result.stdout).toContain("failed scripts/check-latest-json.jq, registry left as is");
expect(curlCalls()).toStrictEqual([]);
});
});

View file

@ -3,24 +3,32 @@
# deleted, installed apps find no update until a valid one is uploaded, so a # deleted, installed apps find no update until a valid one is uploaded, so a
# manifest that fails this check stops the job and leaves the registry as is. # manifest that fails this check stops the job and leaves the registry as is.
# #
# Run as: jq -e --arg version <X.Y.Z> -f scripts/check-latest-json.jq latest.json # Run as: jq -e -s --arg version <X.Y.Z> -f scripts/check-latest-json.jq latest.json
# -e turns false, null and any jq error (missing field, wrong type, invalid # -e turns false, null and any jq error (missing field, wrong type, invalid
# JSON) into a non-zero exit status: the check fails closed. # JSON) into a non-zero exit status: the check fails closed.
# -s reads the whole file as one array of documents, and the filter wants
# exactly one (#419). Without it, jq 1.6 runs the filter once per document: an
# empty file gives no output, which -e lets through with status 0, and two
# documents are judged on the last one alone. jq 1.7 refuses the empty file
# (status 4) but still passes the two documents.
# #
# The key set must stay the one scripts/build-latest-json.sh writes and # The key set must stay the one scripts/build-latest-json.sh writes and
# .claude/skills/release/scripts/verify-release.sh expects. # .claude/skills/release/scripts/verify-release.sh expects.
# scripts/build-latest-json.test.mjs runs this file on the script's output and # scripts/build-latest-json.test.mjs runs this file on the script's output and
# on broken manifests, and compares the three lists, on every pull request # on broken manifests, and compares the three lists, on every pull request
# through check-frontend: a misaligned list shows before the tag, not at it. # through check-frontend: a misaligned list shows before the tag, not at it.
# It also runs the text of release.yml's Publish step, which calls this file.
# Written for jq 1.6, the version ubuntu:22.04 ships. # Written for jq 1.6, the version ubuntu:22.04 ships.
# #
# Not checked here: that each signature belongs to its own file. The script's # Not checked here: that each signature belongs to its own file. The script's
# test checks it before the merge, and verify-release.sh after publication # test checks it before the merge, and verify-release.sh after publication
# (the file: field of the minisign trusted comment). # (the file: field of the minisign trusted comment).
.version == $version length == 1
and (.[0]
| .version == $version
and (.platforms | keys) == ["linux-x86_64-deb", "linux-x86_64-rpm", "windows-x86_64"] and (.platforms | keys) == ["linux-x86_64-deb", "linux-x86_64-rpm", "windows-x86_64"]
and all(.platforms[]; (.signature | type) == "string" and (.signature | length) > 0) and all(.platforms[]; (.signature | type) == "string" and (.signature | length) > 0)
and (.platforms["linux-x86_64-deb"].url | endswith(".deb")) and (.platforms["linux-x86_64-deb"].url | endswith(".deb"))
and (.platforms["linux-x86_64-rpm"].url | endswith(".rpm")) and (.platforms["linux-x86_64-rpm"].url | endswith(".rpm"))
and (.platforms["windows-x86_64"].url | endswith(".exe")) and (.platforms["windows-x86_64"].url | endswith(".exe")))

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()

33
src-tauri/Cargo.lock generated
View file

@ -100,15 +100,6 @@ version = "1.0.101"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5f0e0fee31ef5ed1ba1316088939cea399010ed7731dba877ed44aeb407a75ea" checksum = "5f0e0fee31ef5ed1ba1316088939cea399010ed7731dba877ed44aeb407a75ea"
[[package]]
name = "arbitrary"
version = "1.4.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c3d036a3c4ab069c7b410a2ce876bd74808d2d0888a82667669f8e783a898bf1"
dependencies = [
"derive_arbitrary",
]
[[package]] [[package]]
name = "argon2" name = "argon2"
version = "0.5.3" version = "0.5.3"
@ -935,17 +926,6 @@ dependencies = [
"serde_core", "serde_core",
] ]
[[package]]
name = "derive_arbitrary"
version = "1.4.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1e567bd82dcff979e4b03460c307b3cdc9e96fde3d73bed1496d2bc75d9dd62a"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.114",
]
[[package]] [[package]]
name = "derive_more" name = "derive_more"
version = "0.99.20" version = "0.99.20"
@ -5363,7 +5343,6 @@ dependencies = [
"tokio", "tokio",
"url", "url",
"windows-sys 0.60.2", "windows-sys 0.60.2",
"zip",
] ]
[[package]] [[package]]
@ -7183,18 +7162,6 @@ dependencies = [
"syn 2.0.114", "syn 2.0.114",
] ]
[[package]]
name = "zip"
version = "4.6.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "caa8cd6af31c3b31c6631b8f483848b91589021b28fffe50adada48d4f4d2ed1"
dependencies = [
"arbitrary",
"crc32fast",
"indexmap 2.13.0",
"memchr",
]
[[package]] [[package]]
name = "zmij" name = "zmij"
version = "1.0.19" version = "1.0.19"

View file

@ -23,7 +23,12 @@ tauri = { version = "2", features = [] }
tauri-plugin-opener = "2" tauri-plugin-opener = "2"
tauri-plugin-sql = { version = "2", features = ["sqlite"] } tauri-plugin-sql = { version = "2", features = ["sqlite"] }
tauri-plugin-dialog = "2" tauri-plugin-dialog = "2"
tauri-plugin-updater = "2" # Built without its default `zip` feature (#455): that feature only pulls
# `zip` on Windows and `tar` on Linux to unpack archived update packages, and
# ours are never archives (`createUpdaterArtifacts: true` serves the NSIS
# `.exe`, the `.deb` and the `.rpm` as they are). Restore it only if
# `createUpdaterArtifacts` becomes "v1Compatible" (docs/qa-update-cycle.md).
tauri-plugin-updater = { version = "2", default-features = false, features = ["rustls-tls"] }
tauri-plugin-process = "2" tauri-plugin-process = "2"
tauri-plugin-deep-link = "2" tauri-plugin-deep-link = "2"
tauri-plugin-single-instance = { version = "2", features = ["deep-link"] } tauri-plugin-single-instance = { version = "2", features = ["deep-link"] }

View file

@ -0,0 +1,60 @@
// UpsellDialog — the locked screen in a modal (#369). No jsdom: react-dom/server
// renders the markup in vitest's node environment, and calling the component as
// a function (UpsellCtas.render.test.tsx pattern) exposes the props it hands
// to UpsellGate and to its close button.
import { describe, it, expect, vi } from "vitest";
import { isValidElement, type ReactElement, type ReactNode } from "react";
import { renderToStaticMarkup } from "react-dom/server";
import { MemoryRouter } from "react-router-dom";
vi.mock("@tauri-apps/plugin-opener", () => ({ openUrl: vi.fn(() => Promise.resolve()) }));
vi.mock("react-i18next", async () => (await import("../../test/i18nMock")).i18nMock());
// The locked screen reads one thing from the license context: a revoked key (#365).
vi.mock("../../contexts/LicenseContext", () => ({ useLicenseContext: () => ({ revoked: false }) }));
import UpsellDialog from "./UpsellDialog";
import UpsellGate from "./UpsellGate";
type Props = Record<string, unknown> & { children?: ReactNode };
function find(node: ReactNode, pred: (el: ReactElement<Props>) => boolean): ReactElement<Props>[] {
if (Array.isArray(node)) return node.flatMap((n) => find(n, pred));
if (!isValidElement(node)) return [];
const el = node as ReactElement<Props>;
return [...(pred(el) ? [el] : []), ...find(el.props.children, pred)];
}
describe("UpsellDialog (#369)", () => {
it("shows the locked screen of the feature in a named modal dialog", () => {
const html = renderToStaticMarkup(
<MemoryRouter>
<UpsellDialog feature="adjustments" onClose={vi.fn()} />
</MemoryRouter>,
);
expect(html).toContain('role="dialog"');
expect(html).toContain('aria-modal="true"');
expect(html).toContain('aria-label="t(upsell.title[tier=t(license.editions.base)])"');
expect(html).toContain("t(upsell.features.adjustments)");
expect(html).toContain("t(upsell.ctaGet[tier=t(license.editions.base)])");
expect(html).toContain("t(upsell.ctaHaveKey)");
expect(html).toContain('aria-label="t(common.close)"');
});
it("closes on its X button and after 'I already have a key'", () => {
const onClose = vi.fn();
const tree = UpsellDialog({ feature: "adjustments", onClose });
const gates = find(tree, (el) => el.type === UpsellGate);
expect(gates).toHaveLength(1);
expect(gates[0].props).toMatchObject({ feature: "adjustments", requiredTier: "base" });
expect(gates[0].props.onNavigate).toBe(onClose);
const close = find(tree, (el) => el.type === "button" && el.props["aria-label"] === "t(common.close)");
expect(close).toHaveLength(1);
(close[0].props.onClick as () => void)();
expect(onClose).toHaveBeenCalledTimes(1);
});
it("asks for the tier the feature needs, not always Base", () => {
const tree = UpsellDialog({ feature: "balance", onClose: vi.fn() });
expect(find(tree, (el) => el.type === UpsellGate)[0].props.requiredTier).toBe("premium");
});
});

View file

@ -0,0 +1,49 @@
import { useTranslation } from "react-i18next";
import { X } from "lucide-react";
import { requiredTierFor, type FeatureKey } from "../../shared/entitlements";
import UpsellGate from "./UpsellGate";
interface UpsellDialogProps {
feature: FeatureKey;
onClose: () => void;
}
/**
* The locked screen in a modal, for a gated action inside a free page (#369:
* the Répartition button of the Transactions page). Same markup as the upsell
* dialog of ProfileSwitcher, which has not moved onto this component yet.
*
* "I already have a key" navigates to the license card and closes the dialog
* through UpsellGate's `onNavigate`, so it does not linger over the destination
* page; the purchase button leaves it open (the product page opens in the
* browser).
*/
export default function UpsellDialog({ feature, onClose }: UpsellDialogProps) {
const { t } = useTranslation();
const requiredTier = requiredTierFor(feature);
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50">
<div
role="dialog"
aria-modal="true"
aria-label={t("upsell.title", { tier: t(`license.editions.${requiredTier}`) })}
className="bg-[var(--card)] rounded-xl shadow-xl w-full max-w-md border border-[var(--border)]"
>
<div className="flex justify-end p-3 pb-0">
<button
type="button"
onClick={onClose}
aria-label={t("common.close")}
className="text-[var(--muted-foreground)] hover:text-[var(--foreground)]"
>
<X size={18} />
</button>
</div>
<div className="px-4 pb-8">
<UpsellGate feature={feature} requiredTier={requiredTier} onNavigate={onClose} />
</div>
</div>
</div>
);
}

View file

@ -0,0 +1,106 @@
// SplitButton — the Répartition button of a transaction row (#369). No jsdom:
// react-dom/server renders the markup in vitest's node environment, and calling
// the component as a function (UpsellCtas.render.test.tsx pattern) hands over
// the button's props, so a click runs without a DOM.
import { describe, it, expect, vi } from "vitest";
import type { ReactElement } from "react";
import { renderToStaticMarkup } from "react-dom/server";
import fr from "../../i18n/locales/fr.json";
import en from "../../i18n/locales/en.json";
vi.mock("react-i18next", async () => (await import("../../test/i18nMock")).i18nMock());
import SplitButton from "./SplitButton";
import type { SplitAccess } from "../../shared/splitGate";
import type { TransactionRow } from "../../shared/types";
const row = (isSplit: boolean): TransactionRow => ({
id: isSplit ? 1 : 2,
date: "2026-09-12",
description: isSplit ? "COSTCO 123" : "IGA 456",
amount: -120,
category_id: 7,
category_name: "Épicerie",
category_color: null,
source_name: "Exemple Desjardins",
notes: null,
is_manually_categorized: true,
is_split: isSplit,
});
const ACCESSES: SplitAccess[] = ["open", "locked", "pending"];
const LOCK = "lucide-lock";
const ORANGE = "text-orange-500";
const markup = (access: SplitAccess, isSplit = false) =>
renderToStaticMarkup(
<SplitButton row={row(isSplit)} access={access} onOpenSplit={vi.fn()} onOpenUpsell={vi.fn()} />,
);
/** One click on the button, through its own props: which callback ran. */
function click(access: SplitAccess, isSplit: boolean) {
const onOpenSplit = vi.fn();
const onOpenUpsell = vi.fn();
const button = SplitButton({ row: row(isSplit), access, onOpenSplit, onOpenUpsell }) as ReactElement<{
onClick: () => void;
}>;
button.props.onClick();
return { split: onOpenSplit.mock.calls.length, upsell: onOpenUpsell.mock.calls.length };
}
describe("SplitButton — what it shows (#369)", () => {
it("shows the lock in the locked state only", () => {
expect(markup("locked")).toContain(LOCK);
expect(markup("open")).not.toContain(LOCK);
expect(markup("pending")).not.toContain(LOCK);
});
it("keeps the orange of a split row in every state, and only for a split row", () => {
for (const access of ACCESSES) {
expect(markup(access, true), access).toContain(ORANGE);
expect(markup(access, false), access).not.toContain(ORANGE);
}
});
it("is disabled while the license is not ready, and only then", () => {
// The attribute itself: the class also names the `disabled:` variants.
expect(markup("pending")).toContain('disabled=""');
expect(markup("open")).not.toContain('disabled=""');
expect(markup("locked")).not.toContain('disabled=""');
});
it("names the edition in the locked title, keeps the usual title otherwise", () => {
expect(markup("locked")).toContain('title="t(transactions.splitLocked[tier=t(license.editions.base)])"');
expect(markup("open")).toContain('title="t(transactions.splitAdjustment)"');
expect(markup("pending")).toContain('title="t(transactions.splitAdjustment)"');
});
});
describe("SplitButton — what a click does (#369)", () => {
// Every row: the decision never depends on the row being split already.
it("locked: opens the upsell, never the split window, on a split row and on a plain one", () => {
expect(click("locked", true)).toEqual({ split: 0, upsell: 1 });
expect(click("locked", false)).toEqual({ split: 0, upsell: 1 });
});
it("open: opens the split window, never the upsell", () => {
expect(click("open", true)).toEqual({ split: 1, upsell: 0 });
expect(click("open", false)).toEqual({ split: 1, upsell: 0 });
});
it("pending: opens nothing", () => {
expect(click("pending", true)).toEqual({ split: 0, upsell: 0 });
expect(click("pending", false)).toEqual({ split: 0, upsell: 0 });
});
});
// The i18n mock renders any key: a key missing from one locale would only show
// up in the app (the raw key in English, or English in French).
describe("transactions.splitLocked in both locales", () => {
it("exists in fr and en and interpolates the edition", () => {
for (const messages of [fr, en]) {
expect(messages.transactions.splitLocked).toContain("{{tier}}");
expect(messages.transactions.splitLocked).not.toBe(messages.transactions.splitAdjustment);
}
});
});

View file

@ -0,0 +1,59 @@
import { useTranslation } from "react-i18next";
import { Lock, Split } from "lucide-react";
import type { TransactionRow } from "../../shared/types";
import { requiredTierFor } from "../../shared/entitlements";
import { SPLIT_FEATURE, type SplitAccess } from "../../shared/splitGate";
interface SplitButtonProps {
row: TransactionRow;
access: SplitAccess;
onOpenSplit: () => void;
onOpenUpsell: () => void;
}
/**
* The Répartition button of a transaction row (#369).
*
* Locked, never hidden: on the Free edition the button keeps its place and its
* icon, carries a lock and opens the upsell instead of the split window, for
* every row, split or not. The orange colour of a split row is kept in every
* state: existing splits stay flagged whatever the edition. While the license
* is not ready ("pending") the button shows no lock and does nothing.
*/
export default function SplitButton({ row, access, onOpenSplit, onOpenUpsell }: SplitButtonProps) {
const { t } = useTranslation();
const locked = access === "locked";
const handleClick = () => {
if (access === "open") onOpenSplit();
else if (access === "locked") onOpenUpsell();
};
return (
<button
type="button"
onClick={handleClick}
disabled={access === "pending"}
className={`relative p-1 rounded hover:bg-[var(--muted)] transition-colors shrink-0 disabled:opacity-50 disabled:cursor-default ${
row.is_split ? "text-orange-500" : "text-[var(--muted-foreground)]"
}`}
title={
locked
? t("transactions.splitLocked", {
tier: t(`license.editions.${requiredTierFor(SPLIT_FEATURE)}`),
})
: t("transactions.splitAdjustment")
}
>
<Split size={14} />
{locked && (
<Lock
size={8}
strokeWidth={3}
aria-hidden="true"
className="absolute bottom-0 right-0 rounded-full bg-[var(--card)]"
/>
)}
</button>
);
}

View file

@ -9,6 +9,7 @@ vi.mock("react-i18next", async () => (await import("../../test/i18nMock")).i18nM
import TransactionTable, { TRANSACTION_CATEGORY_TARGET } from "./TransactionTable"; import TransactionTable, { TRANSACTION_CATEGORY_TARGET } from "./TransactionTable";
import type { Category, TransactionRow } from "../../shared/types"; import type { Category, TransactionRow } from "../../shared/types";
import type { SplitAccess } from "../../shared/splitGate";
const HIGHLIGHT = 'data-onboarding-highlight="true"'; const HIGHLIGHT = 'data-onboarding-highlight="true"';
const count = (html: string, needle: string) => html.split(needle).length - 1; const count = (html: string, needle: string) => html.split(needle).length - 1;
@ -23,7 +24,12 @@ const GROCERIES: Category = {
created_at: "", created_at: "",
}; };
const row = (id: number, description: string, category: Category | null = null): TransactionRow => ({ const row = (
id: number,
description: string,
category: Category | null = null,
isSplit = false,
): TransactionRow => ({
id, id,
date: "2026-09-12", date: "2026-09-12",
description, description,
@ -34,13 +40,13 @@ const row = (id: number, description: string, category: Category | null = null):
source_name: "Exemple Desjardins", source_name: "Exemple Desjardins",
notes: null, notes: null,
is_manually_categorized: false, is_manually_categorized: false,
is_split: false, is_split: isSplit,
}); });
/** The guided entry: uncategorized rows only. */ /** The guided entry: uncategorized rows only. */
const UNCATEGORIZED = [row(1, "IGA 123"), row(2, "METRO 456"), row(3, "SAQ 789")]; const UNCATEGORIZED = [row(1, "IGA 123"), row(2, "METRO 456"), row(3, "SAQ 789")];
const markup = (rows: TransactionRow[], onboardingTarget?: boolean) => const markup = (rows: TransactionRow[], onboardingTarget?: boolean, splitAccess: SplitAccess = "open") =>
renderToStaticMarkup( renderToStaticMarkup(
<TransactionTable <TransactionTable
rows={rows} rows={rows}
@ -53,6 +59,7 @@ const markup = (rows: TransactionRow[], onboardingTarget?: boolean) =>
onLoadSplitChildren={vi.fn(async () => [])} onLoadSplitChildren={vi.fn(async () => [])}
onSaveSplit={vi.fn(async () => undefined)} onSaveSplit={vi.fn(async () => undefined)}
onDeleteSplit={vi.fn(async () => undefined)} onDeleteSplit={vi.fn(async () => undefined)}
splitAccess={splitAccess}
onboardingTarget={onboardingTarget} onboardingTarget={onboardingTarget}
/>, />,
); );
@ -100,3 +107,26 @@ describe("TransactionTable — getting-started target (#399)", () => {
expect(html).not.toContain("data-onboarding"); expect(html).not.toContain("data-onboarding");
}); });
}); });
describe("TransactionTable — the Répartition button (#369)", () => {
/** A split row, then a plain one, both categorized (the button needs a category). */
const CATEGORIZED = [row(1, "COSTCO 123", GROCERIES, true), row(2, "IGA 456", GROCERIES)];
/** The rows' split buttons, in order (their title names them). */
const splitButtons = (html: string) =>
html.match(/<button type="button"[^>]*title="t\(transactions\.split(?:Adjustment|Locked\[[^"]*\])\)"[^>]*>.*?<\/button>/g) ?? [];
it("Free: every categorized row's button carries the lock, the split row keeps its orange", () => {
const buttons = splitButtons(markup(CATEGORIZED, undefined, "locked"));
expect(buttons).toHaveLength(2);
expect(buttons.every((b) => b.includes("lucide-lock"))).toBe(true);
expect(buttons[0]).toContain("text-orange-500");
expect(buttons[1]).not.toContain("text-orange-500");
});
it("Base and Premium: no lock, the split row keeps its orange", () => {
const buttons = splitButtons(markup(CATEGORIZED));
expect(buttons).toHaveLength(2);
expect(buttons.some((b) => b.includes("lucide-lock"))).toBe(false);
expect(buttons[0]).toContain("text-orange-500");
});
});

View file

@ -1,6 +1,6 @@
import { Fragment, useState, useMemo } from "react"; import { Fragment, useState, useMemo } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { ChevronUp, ChevronDown, MessageSquare, Tag, Split, Link2 } from "lucide-react"; import { ChevronUp, ChevronDown, MessageSquare, Tag, Link2 } from "lucide-react";
import type { import type {
TransactionRow, TransactionRow,
TransactionSort, TransactionSort,
@ -8,9 +8,12 @@ import type {
SplitChild, SplitChild,
} from "../../shared/types"; } from "../../shared/types";
import type { LinkedTransferTooltipRow } from "../../services/balance.service"; import type { LinkedTransferTooltipRow } from "../../services/balance.service";
import { SPLIT_FEATURE, type SplitAccess } from "../../shared/splitGate";
import { onboardingTargetAttributes } from "../../utils/onboardingPath"; import { onboardingTargetAttributes } from "../../utils/onboardingPath";
import CategoryCombobox from "../shared/CategoryCombobox"; import CategoryCombobox from "../shared/CategoryCombobox";
import UpsellDialog from "../shared/UpsellDialog";
import SplitAdjustmentModal from "./SplitAdjustmentModal"; import SplitAdjustmentModal from "./SplitAdjustmentModal";
import SplitButton from "./SplitButton";
interface TransactionTableProps { interface TransactionTableProps {
rows: TransactionRow[]; rows: TransactionRow[];
@ -23,6 +26,12 @@ interface TransactionTableProps {
onLoadSplitChildren: (parentId: number) => Promise<SplitChild[]>; onLoadSplitChildren: (parentId: number) => Promise<SplitChild[]>;
onSaveSplit: (parentId: number, entries: Array<{ category_id: number; amount: number; description: string }>) => Promise<void>; onSaveSplit: (parentId: number, entries: Array<{ category_id: number; amount: number; description: string }>) => Promise<void>;
onDeleteSplit: (parentId: number) => Promise<void>; onDeleteSplit: (parentId: number) => Promise<void>;
/**
* What the Répartition button does (#369): `splitAccessFor` over
* `useEntitlement(SPLIT_FEATURE)`. Required on purpose: a default of "open"
* would let a new caller leave the split ungated without a word.
*/
splitAccess: SplitAccess;
onRowContextMenu?: (event: React.MouseEvent, row: TransactionRow) => void; onRowContextMenu?: (event: React.MouseEvent, row: TransactionRow) => void;
/** /**
* Issue #142 — when supplied, a small Link2 icon appears next to the * Issue #142 — when supplied, a small Link2 icon appears next to the
@ -69,6 +78,7 @@ export default function TransactionTable({
onLoadSplitChildren, onLoadSplitChildren,
onSaveSplit, onSaveSplit,
onDeleteSplit, onDeleteSplit,
splitAccess,
onRowContextMenu, onRowContextMenu,
linkedTransfersByTxId, linkedTransfersByTxId,
onboardingTarget, onboardingTarget,
@ -80,6 +90,7 @@ export default function TransactionTable({
const [keywordText, setKeywordText] = useState(""); const [keywordText, setKeywordText] = useState("");
const [keywordSaved, setKeywordSaved] = useState<number | null>(null); const [keywordSaved, setKeywordSaved] = useState<number | null>(null);
const [splitRow, setSplitRow] = useState<TransactionRow | null>(null); const [splitRow, setSplitRow] = useState<TransactionRow | null>(null);
const [showSplitUpsell, setShowSplitUpsell] = useState(false);
const noCategoryExtra = useMemo( const noCategoryExtra = useMemo(
() => [{ value: "", label: t("transactions.table.noCategory") }], () => [{ value: "", label: t("transactions.table.noCategory") }],
[t] [t]
@ -235,17 +246,12 @@ export default function TransactionTable({
> >
<Tag size={14} /> <Tag size={14} />
</button> </button>
<button <SplitButton
onClick={() => setSplitRow(row)} row={row}
className={`p-1 rounded hover:bg-[var(--muted)] transition-colors shrink-0 ${ access={splitAccess}
row.is_split onOpenSplit={() => setSplitRow(row)}
? "text-orange-500" onOpenUpsell={() => setShowSplitUpsell(true)}
: "text-[var(--muted-foreground)]" />
}`}
title={t("transactions.splitAdjustment")}
>
<Split size={14} />
</button>
</> </>
)} )}
</div> </div>
@ -326,7 +332,14 @@ export default function TransactionTable({
))} ))}
</tbody> </tbody>
</table> </table>
{splitRow && ( {/*
The edition can change under an open window: the revocation check at
startup refreshes the license. The split window never stays open once
the edition resolves to Free, but a refresh ("pending") keeps it
mounted: `refresh()` goes back through "loading", and an `=== "open"`
guard would unmount the window and lose what was being typed.
*/}
{splitRow && splitAccess !== "locked" && (
<SplitAdjustmentModal <SplitAdjustmentModal
transaction={splitRow} transaction={splitRow}
categories={categories} categories={categories}
@ -336,6 +349,10 @@ export default function TransactionTable({
onClose={() => setSplitRow(null)} onClose={() => setSplitRow(null)}
/> />
)} )}
{/* Symmetric: the upsell goes away once the edition resolves to Base or Premium. */}
{showSplitUpsell && splitAccess !== "open" && (
<UpsellDialog feature={SPLIT_FEATURE} onClose={() => setShowSplitUpsell(false)} />
)}
</div> </div>
); );
} }

View file

@ -0,0 +1,142 @@
// TransactionTable — what the Répartition button opens (#369).
//
// A static render never shows a window: they open on a click, through the
// table's own state. React's hooks come from the shared harness
// (src/test/hookHarness.ts, #409): the table runs as a plain function, its
// state survives from one call to the next as between two renders of a mounted
// component, and the test clicks through the props the table hands to each
// SplitButton, then renders again to read what opened.
import { describe, it, expect, vi, beforeEach } from "vitest";
import { isValidElement, type ReactElement, type ReactNode } from "react";
vi.mock("react", async (orig) => (await import("../../test/hookHarness")).fakeReact(await orig()));
vi.mock("react-i18next", async () => (await import("../../test/i18nMock")).i18nMock());
import { hookHarness } from "../../test/hookHarness";
import TransactionTable from "./TransactionTable";
import SplitButton from "./SplitButton";
import SplitAdjustmentModal from "./SplitAdjustmentModal";
import UpsellDialog from "../shared/UpsellDialog";
import type { SplitAccess } from "../../shared/splitGate";
import type { Category, TransactionRow } from "../../shared/types";
const harness = hookHarness();
type Props = Record<string, unknown> & { children?: ReactNode };
function find(node: ReactNode, type: unknown): ReactElement<Props>[] {
if (Array.isArray(node)) return node.flatMap((n) => find(n, type));
if (!isValidElement(node)) return [];
const el = node as ReactElement<Props>;
return [...(el.type === type ? [el] : []), ...find(el.props.children, type)];
}
const GROCERIES: Category = {
id: 7,
name: "Épicerie",
type: "expense",
is_active: true,
is_inputable: true,
sort_order: 1,
created_at: "",
};
const row = (id: number, isSplit: boolean): TransactionRow => ({
id,
date: "2026-09-12",
description: `TX ${id}`,
amount: -42.5,
category_id: GROCERIES.id,
category_name: GROCERIES.name,
category_color: null,
source_name: "Exemple Desjardins",
notes: null,
is_manually_categorized: true,
is_split: isSplit,
});
/** A split row and a plain one: the button must behave the same on both. */
const ROWS = [row(1, true), row(2, false)];
/** One render of the mounted table, under the given access. */
const render = (splitAccess: SplitAccess) =>
harness.render(() =>
TransactionTable({
rows: ROWS,
sort: { column: "date", direction: "desc" },
categories: [GROCERIES],
onSort: vi.fn(),
onCategoryChange: vi.fn(),
onNotesChange: vi.fn(),
onAddKeyword: vi.fn(async () => undefined),
onLoadSplitChildren: vi.fn(async () => []),
onSaveSplit: vi.fn(async () => undefined),
onDeleteSplit: vi.fn(async () => undefined),
splitAccess,
}),
);
const splitButtons = (tree: ReactNode) => find(tree, SplitButton);
const upsells = (tree: ReactNode) => find(tree, UpsellDialog);
const splitWindows = (tree: ReactNode) => find(tree, SplitAdjustmentModal);
beforeEach(() => harness.clear());
describe("TransactionTable — the Répartition button's wiring (#369)", () => {
it("hands the table's access to every row's button, and opens nothing before a click", () => {
for (const access of ["open", "locked", "pending"] as const) {
harness.clear();
const tree = render(access);
const buttons = splitButtons(tree);
expect(buttons.map((b) => b.props.access)).toEqual([access, access]);
expect(buttons.map((b) => b.props.row)).toEqual(ROWS);
expect(upsells(tree)).toHaveLength(0);
expect(splitWindows(tree)).toHaveLength(0);
}
});
it("Free: the click opens the upsell for 'adjustments', never the split window, on any row", () => {
for (const index of [0, 1]) {
harness.clear();
(splitButtons(render("locked"))[index].props.onOpenUpsell as () => void)();
const opened = render("locked");
const dialogs = upsells(opened);
expect(dialogs, `row ${index}`).toHaveLength(1);
expect(dialogs[0].props.feature).toBe("adjustments");
expect(splitWindows(opened)).toHaveLength(0);
// The dialog's close hands control back to the table.
(dialogs[0].props.onClose as () => void)();
expect(upsells(render("locked"))).toHaveLength(0);
}
});
it("Base and Premium: the click opens the split window on its own row, no upsell", () => {
(splitButtons(render("open"))[1].props.onOpenSplit as () => void)();
const opened = render("open");
const windows = splitWindows(opened);
expect(windows).toHaveLength(1);
expect(windows[0].props.transaction).toBe(ROWS[1]);
expect(upsells(opened)).toHaveLength(0);
});
// One click arms one window: an edition change never brings up the other one.
it("an open split window survives a license refresh and closes once the edition is Free", () => {
(splitButtons(render("open"))[0].props.onOpenSplit as () => void)();
const refreshing = render("pending");
expect(splitWindows(refreshing)).toHaveLength(1);
expect(upsells(refreshing)).toHaveLength(0);
const downgraded = render("locked");
expect(splitWindows(downgraded)).toHaveLength(0);
expect(upsells(downgraded)).toHaveLength(0);
});
it("an open upsell survives a license refresh and goes away once the edition is Base", () => {
(splitButtons(render("locked"))[0].props.onOpenUpsell as () => void)();
const refreshing = render("pending");
expect(upsells(refreshing)).toHaveLength(1);
expect(splitWindows(refreshing)).toHaveLength(0);
const upgraded = render("open");
expect(upsells(upgraded)).toHaveLength(0);
expect(splitWindows(upgraded)).toHaveLength(0);
});
});

View file

@ -381,6 +381,7 @@
"keywordAdded": "Keyword added", "keywordAdded": "Keyword added",
"keywordPlaceholder": "Keyword to match...", "keywordPlaceholder": "Keyword to match...",
"splitAdjustment": "Split adjustment", "splitAdjustment": "Split adjustment",
"splitLocked": "Split adjustment — {{tier}} feature",
"splitBase": "Base", "splitBase": "Base",
"splitAdjusted": "Adjusted", "splitAdjusted": "Adjusted",
"splitCategory": "Category", "splitCategory": "Category",
@ -1122,7 +1123,7 @@
"Inline category assignment via dropdown", "Inline category assignment via dropdown",
"Auto-categorize based on keyword rules", "Auto-categorize based on keyword rules",
"Add keywords directly from a transaction", "Add keywords directly from a transaction",
"Split a transaction across multiple categories", "Split a transaction across multiple categories (Base edition and above)",
"Transaction notes" "Transaction notes"
], ],
"steps": [ "steps": [
@ -1131,13 +1132,13 @@
"To categorize a transaction, click its category dropdown and select a category", "To categorize a transaction, click its category dropdown and select a category",
"To auto-categorize all uncategorized transactions, click the Auto-categorize button", "To auto-categorize all uncategorized transactions, click the Auto-categorize button",
"To add a keyword rule from a transaction, right-click its row, then “Add as keyword”; on a transaction that already has a category, the tag icon also adds a keyword to that category", "To add a keyword rule from a transaction, right-click its row, then “Add as keyword”; on a transaction that already has a category, the tag icon also adds a keyword to that category",
"To split a transaction across categories, use the Split button and add amounts per category" "To split a transaction across categories, use the Split button and add amounts per category (Base edition and above; on the Free edition the button shows a lock and opens the unlock screen)"
], ],
"tips": [ "tips": [
"Use the quick period buttons (This month, Last month, etc.) for fast date filtering", "Use the quick period buttons (This month, Last month, etc.) for fast date filtering",
"Auto-categorize only affects uncategorized transactions — it won't overwrite manual assignments", "Auto-categorize only affects uncategorized transactions — it won't overwrite manual assignments",
"Adding a keyword from a transaction pre-fills the category so you can quickly build rules", "Adding a keyword from a transaction pre-fills the category so you can quickly build rules",
"Split transactions display a visual indicator and show the breakdown details" "Split transactions display a visual indicator whatever the edition; the breakdown opens from the Split button (Base edition and above)"
] ]
}, },
"categories": { "categories": {
@ -1331,13 +1332,13 @@
"title": "Editions", "title": "Editions",
"overview": "Simpl'Résultat comes in three editions — Free, Base and Premium. The edition determines which modules are accessible; it never touches your data, which stays local and complete whatever the active edition.", "overview": "Simpl'Résultat comes in three editions — Free, Base and Premium. The edition determines which modules are accessible; it never touches your data, which stays local and complete whatever the active edition.",
"features": [ "features": [
"Free — Dashboard, CSV Import, Transactions, Categories, the Trends report (and the Reports hub), encrypted export/import and the changelog, with a single profile", "Free — Dashboard, CSV Import, Transactions (without splits), Categories, the Trends report (and the Reports hub), encrypted export/import and the changelog, with a single profile",
"Base — everything in Free, plus Budget, Adjustments, the advanced reports (Highlights, Compare, Category analysis, Cards), multiple profiles and automatic updates", "Base — everything in Free, plus Budget, Adjustments, transaction splits, the advanced reports (Highlights, Compare, Category analysis, Cards), multiple profiles and automatic updates",
"Premium — everything in Base, plus the full Balance module (net worth, per-security detail, market prices)", "Premium — everything in Base, plus the full Balance module (net worth, per-security detail, market prices)",
"Modules above your edition stay visible but locked: a lock badge shows in the sidebar and on the report tiles — opening one shows the unlock screen" "Modules above your edition stay visible but locked: a lock badge shows in the sidebar, on the report tiles and on the Split button of the Transactions page — opening one shows the unlock screen"
], ],
"steps": [ "steps": [
"Look for the lock badge in the sidebar or on the Reports hub tiles: it marks the modules above your edition", "Look for the lock badge in the sidebar, on the Reports hub tiles or on the Split button of the Transactions page: it marks what is above your edition",
"Click a locked module to see which edition it requires", "Click a locked module to see which edition it requires",
"To buy the Base edition, click \"Get Base\": the product page opens in your browser (Premium isn't sold online yet)", "To buy the Base edition, click \"Get Base\": the product page opens in your browser (Premium isn't sold online yet)",
"Once you have a license key, click \"I already have a key\" (or go to Settings → Users) and enter it", "Once you have a license key, click \"I already have a key\" (or go to Settings → Users) and enter it",
@ -1346,6 +1347,7 @@
"tips": [ "tips": [
"Locking is never destructive: if your edition goes down (expired, invalid, revoked or removed key), the data of locked modules — budgets, adjustments, balance snapshots, profiles — is fully kept and reappears as soon as a valid key is entered", "Locking is never destructive: if your edition goes down (expired, invalid, revoked or removed key), the data of locked modules — budgets, adjustments, balance snapshots, profiles — is fully kept and reappears as soon as a valid key is entered",
"On the Free edition your active profile always stays accessible — only creating an extra profile and switching to another profile are locked", "On the Free edition your active profile always stays accessible — only creating an extra profile and switching to another profile are locked",
"On the Free edition, splits made earlier stay in your data: they are still counted in the reports and the dashboard, and still flagged in the transaction list — only opening and editing a split are locked",
"The license key applies to the whole machine, not to one profile: it unlocks the modules for every profile on this computer", "The license key applies to the whole machine, not to one profile: it unlocks the modules for every profile on this computer",
"On the Free edition automatic updates are locked: the Updates card (Settings → System) offers to download the latest version from the product page, and a manual install stays free", "On the Free edition automatic updates are locked: the Updates card (Settings → System) offers to download the latest version from the product page, and a manual install stays free",
"The License card (Settings → Users) shows your edition and when the key expires; a key valid for more than 50 years shows \"No expiry date\"", "The License card (Settings → Users) shows your edition and when the key expires; a key valid for more than 50 years shows \"No expiry date\"",

View file

@ -381,6 +381,7 @@
"keywordAdded": "Mot-clé ajouté", "keywordAdded": "Mot-clé ajouté",
"keywordPlaceholder": "Mot-clé à rechercher...", "keywordPlaceholder": "Mot-clé à rechercher...",
"splitAdjustment": "Répartition", "splitAdjustment": "Répartition",
"splitLocked": "Répartition — fonctionnalité {{tier}}",
"splitBase": "Base", "splitBase": "Base",
"splitAdjusted": "Ajusté", "splitAdjusted": "Ajusté",
"splitCategory": "Catégorie", "splitCategory": "Catégorie",
@ -1122,7 +1123,7 @@
"Assignation de catégorie en ligne via menu déroulant", "Assignation de catégorie en ligne via menu déroulant",
"Auto-catégorisation basée sur les règles de mots-clés", "Auto-catégorisation basée sur les règles de mots-clés",
"Ajout de mots-clés directement depuis une transaction", "Ajout de mots-clés directement depuis une transaction",
"Répartition (split) d'une transaction sur plusieurs catégories", "Répartition (split) d'une transaction sur plusieurs catégories (édition Base et plus)",
"Notes sur les transactions" "Notes sur les transactions"
], ],
"steps": [ "steps": [
@ -1131,13 +1132,13 @@
"Pour catégoriser une transaction, cliquez sur son menu déroulant de catégorie et sélectionnez une catégorie", "Pour catégoriser une transaction, cliquez sur son menu déroulant de catégorie et sélectionnez une catégorie",
"Pour auto-catégoriser toutes les transactions non catégorisées, cliquez sur le bouton Auto-catégoriser", "Pour auto-catégoriser toutes les transactions non catégorisées, cliquez sur le bouton Auto-catégoriser",
"Pour ajouter une règle de mot-clé depuis une transaction, faites un clic droit sur sa ligne, puis « Ajouter comme mot-clé » ; sur une transaction déjà classée, l'icône étiquette ajoute aussi un mot-clé à sa catégorie", "Pour ajouter une règle de mot-clé depuis une transaction, faites un clic droit sur sa ligne, puis « Ajouter comme mot-clé » ; sur une transaction déjà classée, l'icône étiquette ajoute aussi un mot-clé à sa catégorie",
"Pour répartir une transaction sur plusieurs catégories, utilisez le bouton Répartition et ajoutez les montants par catégorie" "Pour répartir une transaction sur plusieurs catégories, utilisez le bouton Répartition et ajoutez les montants par catégorie (édition Base et plus ; en édition Gratuite, le bouton porte un cadenas et ouvre l'écran de déverrouillage)"
], ],
"tips": [ "tips": [
"Utilisez les boutons de période rapide (Ce mois, Mois dernier, etc.) pour filtrer rapidement par date", "Utilisez les boutons de période rapide (Ce mois, Mois dernier, etc.) pour filtrer rapidement par date",
"L'auto-catégorisation n'affecte que les transactions non catégorisées — elle n'écrase pas les assignations manuelles", "L'auto-catégorisation n'affecte que les transactions non catégorisées — elle n'écrase pas les assignations manuelles",
"Ajouter un mot-clé depuis une transaction pré-remplit la catégorie pour construire rapidement vos règles", "Ajouter un mot-clé depuis une transaction pré-remplit la catégorie pour construire rapidement vos règles",
"Les transactions réparties affichent un indicateur visuel et le détail de la répartition" "Les transactions réparties affichent un indicateur visuel, quelle que soit l'édition ; le détail de la répartition s'ouvre par le bouton Répartition (édition Base et plus)"
] ]
}, },
"categories": { "categories": {
@ -1331,13 +1332,13 @@
"title": "Éditions", "title": "Éditions",
"overview": "Simpl'Résultat existe en trois éditions — Gratuite, Base et Premium. L'édition détermine quels modules sont accessibles ; elle ne touche jamais à vos données, qui restent locales et complètes quelle que soit l'édition active.", "overview": "Simpl'Résultat existe en trois éditions — Gratuite, Base et Premium. L'édition détermine quels modules sont accessibles ; elle ne touche jamais à vos données, qui restent locales et complètes quelle que soit l'édition active.",
"features": [ "features": [
"Gratuite — Tableau de bord, Import CSV, Transactions, Catégories, rapport Tendances (et le hub Rapports), export/import chiffré et journal des modifications, avec un profil", "Gratuite — Tableau de bord, Import CSV, Transactions (sans la Répartition), Catégories, rapport Tendances (et le hub Rapports), export/import chiffré et journal des modifications, avec un profil",
"Base — tout de la Gratuite, plus Budget, Ajustements, les rapports avancés (Faits saillants, Comparables, Analyse par catégorie, Cartes), les profils multiples et les mises à jour automatiques", "Base — tout de la Gratuite, plus Budget, Ajustements, la Répartition des transactions, les rapports avancés (Faits saillants, Comparables, Analyse par catégorie, Cartes), les profils multiples et les mises à jour automatiques",
"Premium — tout de la Base, plus le module Bilan complet (patrimoine, détail par titre, cours du marché)", "Premium — tout de la Base, plus le module Bilan complet (patrimoine, détail par titre, cours du marché)",
"Les modules au-dessus de votre édition restent visibles mais verrouillés : cadenas dans la barre latérale et sur les tuiles de rapports — les ouvrir affiche l'écran de déverrouillage" "Les modules au-dessus de votre édition restent visibles mais verrouillés : cadenas dans la barre latérale, sur les tuiles de rapports et sur le bouton Répartition de la page Transactions — les ouvrir affiche l'écran de déverrouillage"
], ],
"steps": [ "steps": [
"Repérez le cadenas dans la barre latérale ou sur les tuiles du hub Rapports : il marque les modules au-dessus de votre édition", "Repérez le cadenas dans la barre latérale, sur les tuiles du hub Rapports ou sur le bouton Répartition de la page Transactions : il marque ce qui est au-dessus de votre édition",
"Cliquez sur un module verrouillé pour voir l'édition requise", "Cliquez sur un module verrouillé pour voir l'édition requise",
"Pour acheter l'édition Base, cliquez sur « Obtenir Base » : la page produit s'ouvre dans votre navigateur (Premium n'est pas encore offert en ligne)", "Pour acheter l'édition Base, cliquez sur « Obtenir Base » : la page produit s'ouvre dans votre navigateur (Premium n'est pas encore offert en ligne)",
"Une fois votre clé de licence en main, cliquez sur « J'ai déjà une clé » (ou allez dans Paramètres → Utilisateurs) et entrez-la", "Une fois votre clé de licence en main, cliquez sur « J'ai déjà une clé » (ou allez dans Paramètres → Utilisateurs) et entrez-la",
@ -1346,6 +1347,7 @@
"tips": [ "tips": [
"Le verrouillage n'est jamais destructif : si votre édition baisse (clé expirée, invalide, révoquée ou retirée), les données des modules verrouillés — budgets, ajustements, snapshots de bilan, profils — sont intégralement conservées et réapparaissent dès qu'une clé valide est entrée", "Le verrouillage n'est jamais destructif : si votre édition baisse (clé expirée, invalide, révoquée ou retirée), les données des modules verrouillés — budgets, ajustements, snapshots de bilan, profils — sont intégralement conservées et réapparaissent dès qu'une clé valide est entrée",
"En édition Gratuite, votre profil actif reste toujours accessible — seuls la création d'un profil supplémentaire et le passage à un autre profil sont verrouillés", "En édition Gratuite, votre profil actif reste toujours accessible — seuls la création d'un profil supplémentaire et le passage à un autre profil sont verrouillés",
"En édition Gratuite, les répartitions déjà faites restent dans vos données : elles sont toujours comptées dans les rapports et le tableau de bord, et toujours signalées dans la liste des transactions — seuls l'ouverture et la modification d'une répartition sont verrouillées",
"La clé de licence s'applique à toute la machine, pas à un profil : elle déverrouille les modules pour tous les profils du poste", "La clé de licence s'applique à toute la machine, pas à un profil : elle déverrouille les modules pour tous les profils du poste",
"En édition Gratuite, les mises à jour automatiques sont verrouillées : la carte Mises à jour (Paramètres → Système) propose de télécharger la dernière version depuis la page produit, et l'installation manuelle reste gratuite", "En édition Gratuite, les mises à jour automatiques sont verrouillées : la carte Mises à jour (Paramètres → Système) propose de télécharger la dernière version depuis la page produit, et l'installation manuelle reste gratuite",
"La carte Licence (Paramètres → Utilisateurs) affiche votre édition et l'échéance de la clé ; une clé valide pour plus de 50 ans indique « Sans échéance »", "La carte Licence (Paramètres → Utilisateurs) affiche votre édition et l'échéance de la clé ; une clé valide pour plus de 50 ans indique « Sans échéance »",

View file

@ -20,9 +20,18 @@ const harness = vi.hoisted(() => ({
rows: [] as TransactionRow[], rows: [] as TransactionRow[],
/** Filters a later dispatch would have set (the fallback, a filter picked by hand). */ /** Filters a later dispatch would have set (the fallback, a filter picked by hand). */
filters: null as Partial<TransactionFilters> | null, filters: null as Partial<TransactionFilters> | null,
/** The license gate the page reads (#369), and the features it asked for. */
entitlement: { allowed: true, ready: true },
entitlementCalls: [] as string[],
})); }));
vi.mock("../contexts/OnboardingContext", () => ({ useOnboarding: () => harness.onboarding })); vi.mock("../contexts/OnboardingContext", () => ({ useOnboarding: () => harness.onboarding }));
vi.mock("../hooks/useEntitlement", () => ({
useEntitlement: (feature: string) => {
harness.entitlementCalls.push(feature);
return harness.entitlement;
},
}));
vi.mock("../hooks/useTransactions", async (importOriginal) => { vi.mock("../hooks/useTransactions", async (importOriginal) => {
const actual = await importOriginal<typeof import("../hooks/useTransactions")>(); const actual = await importOriginal<typeof import("../hooks/useTransactions")>();
return { return {
@ -131,6 +140,8 @@ beforeEach(() => {
harness.onboarding = path(IMPORTED); harness.onboarding = path(IMPORTED);
harness.rows = []; harness.rows = [];
harness.filters = null; harness.filters = null;
harness.entitlement = { allowed: true, ready: true };
harness.entitlementCalls = [];
}); });
describe("TransactionsPage — the guided entry (#399)", () => { describe("TransactionsPage — the guided entry (#399)", () => {
@ -235,3 +246,62 @@ describe("TransactionsPage — the step card (#399)", () => {
expect(html).not.toContain('href="/budget"'); expect(html).not.toContain('href="/budget"');
}); });
}); });
describe("TransactionsPage — the Répartition button follows the edition (#369)", () => {
/** Categorized rows, one of them split: the button shows on categorized rows only. */
const categorized = (id: number, description: string, isSplit = false): TransactionRow => ({
...row(id, description),
category_id: 7,
category_name: "Épicerie",
is_split: isSplit,
});
/**
* The split buttons only, named by their title: the pagination always renders
* a disabled button of its own, so the page's markup as a whole says nothing.
*/
const splitButtons = (html: string) =>
html.match(/<button type="button"[^>]*title="t\(transactions\.split(?:Adjustment|Locked\[[^"]*\])\)"[^>]*>.*?<\/button>/g) ?? [];
beforeEach(() => {
harness.rows = [categorized(1, "COSTCO 123", true), categorized(2, "IGA 456")];
});
it("asks the license for 'adjustments'", () => {
render();
expect(harness.entitlementCalls).toContain("adjustments");
});
it("Free, license ready: every button carries the lock and names the edition", () => {
harness.entitlement = { allowed: false, ready: true };
const buttons = splitButtons(render());
expect(buttons).toHaveLength(2);
for (const button of buttons) {
expect(button).toContain("lucide-lock");
expect(button).toContain("t(transactions.splitLocked[tier=t(license.editions.base)])");
expect(button).not.toContain('disabled=""');
}
});
it("Base, license ready: no lock, the buttons work as before", () => {
const buttons = splitButtons(render());
expect(buttons).toHaveLength(2);
for (const button of buttons) {
expect(button).not.toContain("lucide-lock");
expect(button).toContain('title="t(transactions.splitAdjustment)"');
expect(button).not.toContain('disabled=""');
}
expect(buttons[0]).toContain("text-orange-500");
});
it("license not ready yet: no lock, the buttons wait", () => {
for (const allowed of [false, true]) {
harness.entitlement = { allowed, ready: false };
const buttons = splitButtons(render());
expect(buttons).toHaveLength(2);
for (const button of buttons) {
expect(button).not.toContain("lucide-lock");
expect(button).toContain('disabled=""');
}
}
});
});

View file

@ -6,6 +6,8 @@ import { PageHelp } from "../components/shared/PageHelp";
import EmptyState from "../components/shared/EmptyState"; import EmptyState from "../components/shared/EmptyState";
import OnboardingStepCard from "../components/onboarding/OnboardingStepCard"; import OnboardingStepCard from "../components/onboarding/OnboardingStepCard";
import { useOnboarding } from "../contexts/OnboardingContext"; import { useOnboarding } from "../contexts/OnboardingContext";
import { useEntitlement } from "../hooks/useEntitlement";
import { SPLIT_FEATURE, splitAccessFor } from "../shared/splitGate";
import { import {
readOnboardingNavState, readOnboardingNavState,
showsStepCard, showsStepCard,
@ -40,6 +42,9 @@ export default function TransactionsPage() {
readOnboardingNavState(location.state).guided ? GUIDED_ENTRY_FILTERS : undefined readOnboardingNavState(location.state).guided ? GUIDED_ENTRY_FILTERS : undefined
); );
const onboarding = useOnboarding(); const onboarding = useOnboarding();
// The Répartition button belongs to the Base edition (#369): locked with the
// upsell on Free, inert while the license is not ready. The page itself stays Free.
const splitGate = useEntitlement(SPLIT_FEATURE);
const [resultMessage, setResultMessage] = useState<string | null>(null); const [resultMessage, setResultMessage] = useState<string | null>(null);
const [menu, setMenu] = useState<{ x: number; y: number; row: TransactionRow } | null>(null); const [menu, setMenu] = useState<{ x: number; y: number; row: TransactionRow } | null>(null);
const [pending, setPending] = useState<TransactionRow | null>(null); const [pending, setPending] = useState<TransactionRow | null>(null);
@ -156,6 +161,7 @@ export default function TransactionsPage() {
onLoadSplitChildren={loadSplitChildren} onLoadSplitChildren={loadSplitChildren}
onSaveSplit={saveSplit} onSaveSplit={saveSplit}
onDeleteSplit={deleteSplit} onDeleteSplit={deleteSplit}
splitAccess={splitAccessFor(splitGate)}
onRowContextMenu={handleRowContextMenu} onRowContextMenu={handleRowContextMenu}
linkedTransfersByTxId={linkedTransfersByTxId} linkedTransfersByTxId={linkedTransfersByTxId}
onboardingTarget={target === "firstRowCategory"} onboardingTarget={target === "firstRowCategory"}

View file

@ -11,7 +11,9 @@ import type { Edition } from "../services/licenseService";
* server only). * server only).
* *
* Modules that stay Free (dashboard, import, transactions, categories, * Modules that stay Free (dashboard, import, transactions, categories,
* reports/trends, export, changelog, docs) have NO key here → never gated. * reports/trends, export, changelog, docs) have NO key here → never gated as
* pages. One action inside a Free page is gated: the Répartition (split) button
* of the Transactions page belongs to `adjustments` (#369, splitGate.ts).
*/ */
export type FeatureKey = export type FeatureKey =
| "budget" | "budget"

View file

@ -0,0 +1,30 @@
import { describe, it, expect } from "vitest";
import { splitAccessFor } from "./splitGate";
import { isEntitled } from "./entitlements";
// The gate as `useEntitlement("adjustments")` builds it, for each edition.
const gateFor = (edition: "free" | "base" | "premium", ready = true) => ({
allowed: isEntitled("adjustments", edition, []),
ready,
});
describe("splitAccessFor (#369)", () => {
it("locks the split on the Free edition once the license is ready", () => {
expect(splitAccessFor(gateFor("free"))).toBe("locked");
});
it("opens the split on Base and Premium", () => {
expect(splitAccessFor(gateFor("base"))).toBe("open");
expect(splitAccessFor(gateFor("premium"))).toBe("open");
});
it("never locks before the license is ready (anti-flash)", () => {
expect(splitAccessFor({ allowed: false, ready: false })).toBe("pending");
});
// Unlike isProfileSwitchLocked, `allowed` does not short-circuit `ready`:
// `ready` is false while loading AND on error, and `allowed` is fail-closed.
it("stays pending while not ready even when allowed (stricter than profileGate)", () => {
expect(splitAccessFor({ allowed: true, ready: false })).toBe("pending");
});
});

38
src/shared/splitGate.ts Normal file
View file

@ -0,0 +1,38 @@
/**
* Pure predicate for the transaction split gate (Base+), issue #369.
*
* Splitting a transaction across categories (the Répartition button of the
* Transactions page) belongs to the `adjustments` feature, as the upsell copy
* already promised (`upsell.features.adjustments`).
*
* NON-destructive by construction: this only decides what the button does.
* Existing splits stay in `transactions` (`parent_transaction_id`,
* `is_split`), stay counted by every report, and keep their orange indicator
* whatever the edition (ADR 0017: the gate blocks access, never data).
*
* Anti-flash rule, deliberately stricter than `isProfileSwitchLocked`: while
* the license is not `ready` the access is "pending" even when `allowed` is
* true. `ready` is false both while the license loads and when it errored (the
* provider's retry loop), and `refresh()` goes back through "loading": the
* button then does nothing and shows no lock, like RequireFeature's neutral
* loader. A Base user's button is therefore disabled for the length of a
* license refresh — never locked, never open on a fail-closed `allowed`.
*/
import type { EntitlementGate } from "./profileGate";
import type { FeatureKey } from "./entitlements";
/**
* The feature the split belongs to. The page reads its gate, the button names
* its edition and the dialog shows its locked screen from this one constant, so
* moving the split (to its own key, to Premium) is a one-line change.
*/
export const SPLIT_FEATURE: FeatureKey = "adjustments";
/** What the split button does: open the split window, show the upsell, or nothing yet. */
export type SplitAccess = "open" | "locked" | "pending";
/** The split button's access, from the gate `useEntitlement(SPLIT_FEATURE)` returns. */
export function splitAccessFor(gate: EntitlementGate): SplitAccess {
if (!gate.ready) return "pending";
return gate.allowed ? "open" : "locked";
}