fonzarely/regine-photos-archiverpublic⑂ Fork 0
⑂ 4db3efa
Commits
⬇ Clone ▾
git clone https://git.rickub.com/fonzarely/regine-photos-archiver.git
git clone ssh://git@rickub.com/fonzarely/regine-photos-archiver.git

Host key fingerprint (ed25519): SHA256:iycHnxEyq0Q7uyVpB7JlznP0G7JrTPXLYRcAU5CSLhc — verify it before your first connect.

reconciliation

Fabien Champigny committed 2026-09-19T17:39:05+02:00 Browse files
4db3efa parent: e849fcb
added packages/regine-cli/src/regine_cli/archive_cmd.py +147 -0
new file mode 100644
@@ -0,0 +1,147 @@
1+"""Commandes `regine checkout` / `regine reconcile` (cf. contracts/cli-checkout-reconcile.md).
2+
3+Façade fine : orchestre `regine_core.archive`, formatte le résultat, ne contient
4+aucune logique métier propre (Principe VI de la constitution).
5+"""
6+
7+from __future__ import annotations
8+
9+import argparse
10+import sys
11+from pathlib import Path
12+
13+from regine_core.archive.checkout import checkout as checkout_dossier
14+from regine_core.archive.manifest import ouvrir_ou_creer
15+from regine_core.archive.reconciliation import DecisionsUtilisateur, archiver, comparer
16+from regine_core.archive.verrou import DossierDejaVerrouilleError, lever
17+
18+
19+def _cmd_checkout(args: argparse.Namespace) -> int:
20+ dossier_archive = Path(args.dossier)
21+ dest_locale = Path(args.local_dest) if args.local_dest else Path.cwd() / dossier_archive.name
22+ formats = args.formats.split(",") if args.formats else None
23+
24+ try:
25+ snapshot = checkout_dossier(dossier_archive, dest_locale, formats=formats)
26+ except DossierDejaVerrouilleError as exc:
27+ print(f"Erreur : {exc}", file=sys.stderr)
28+ return 1
29+
30+ print(f"{len(snapshot.fichiers)} fichier(s) sortis vers {dest_locale}")
31+ snapshot.manifest.conn.close()
32+ return 0
33+
34+
35+def _afficher_point_avant_archive(rapport) -> None:
36+ print("Point avant archive :")
37+ for changement in rapport.changements:
38+ if changement.categorie == "deplacement":
39+ print(f" [déplacement] {changement.chemin_ancien} -> {changement.chemin}")
40+ else:
41+ print(f" [{changement.categorie}] {changement.chemin}")
42+ if not rapport.changements:
43+ print(" (aucun changement)")
44+
45+
46+def _cmd_reconcile(args: argparse.Namespace) -> int:
47+ dossier_archive = Path(args.dossier)
48+ dossier_local = Path(args.local_dest) if args.local_dest else Path.cwd() / dossier_archive.name
49+
50+ manifest = ouvrir_ou_creer(dossier_archive)
51+ # Reconstruit un ManifestSnapshot à partir du manifeste déjà sur l'archive
52+ # (le checkout d'origine peut avoir eu lieu dans une session précédente).
53+ from regine_core.archive.checkout import ( # noqa: PLC0415
54+ FichierManifesteEntry,
55+ ManifestSnapshot,
56+ )
57+
58+ lignes = manifest.conn.execute(
59+ "SELECT chemin_relatif, taille, hash_fichier_entier, hash_image_only, identifiant_perenne "
60+ "FROM fichiers"
61+ ).fetchall()
62+ fichiers = {
63+ ligne[0]: FichierManifesteEntry(
64+ chemin_relatif=ligne[0],
65+ taille=ligne[1],
66+ hash_fichier_entier=ligne[2],
67+ hash_image_only=ligne[3],
68+ identifiant_perenne=ligne[4],
69+ )
70+ for ligne in lignes
71+ }
72+ snapshot = ManifestSnapshot(
73+ dossier_archive=dossier_archive,
74+ dossier_local=dossier_local,
75+ fichiers=fichiers,
76+ formats=None,
77+ manifest=manifest,
78+ )
79+
80+ rapport = comparer(snapshot, dossier_local)
81+ _afficher_point_avant_archive(rapport)
82+
83+ if not rapport.changements:
84+ # Rien à réconcilier : la session se termine sans écriture, mais le verrou
85+ # doit être levé (FR-016 s'applique aussi à une réconciliation "à vide").
86+ lever(manifest)
87+ manifest.conn.close()
88+ return 0
89+
90+ decisions = DecisionsUtilisateur()
91+
92+ for anomalie in rapport.anomalies:
93+ reponse = (
94+ input(
95+ f"Anomalie sur {anomalie.chemin} : confirmer malgré tout (c) ou restaurer depuis "
96+ f"l'archive (r) ? [c/r] "
97+ )
98+ .strip()
99+ .lower()
100+ )
101+ decisions.resolutions_anomalies[str(anomalie.chemin)] = (
102+ "confirmer" if reponse == "c" else "restaurer"
103+ )
104+
105+ for nouveau in (c for c in rapport.changements if c.categorie == "nouveau"):
106+ question = f"Nouveau fichier {nouveau.chemin} : archiver (a) ou laisser local (l) ? [a/l] "
107+ reponse = input(question)
108+ if reponse.strip().lower() == "a":
109+ decisions.nouveaux_fichiers_a_archiver.add(str(nouveau.chemin))
110+
111+ confirmation = input("Confirmer l'écriture des changements normaux sur l'archive ? [o/n] ")
112+ decisions.confirmer_changements_normaux = confirmation.strip().lower() == "o"
113+
114+ archiver(snapshot, rapport, decisions)
115+ print("Réconciliation terminée.")
116+ manifest.conn.close()
117+ return 0
118+
119+
120+def construire_analyseur() -> argparse.ArgumentParser:
121+ analyseur = argparse.ArgumentParser(prog="regine")
122+ sous_commandes = analyseur.add_subparsers(dest="commande", required=True)
123+
124+ checkout_parser = sous_commandes.add_parser("checkout", help="Sort un dossier de l'archive")
125+ checkout_parser.add_argument("dossier")
126+ checkout_parser.add_argument("--formats", default=None)
127+ checkout_parser.add_argument("--local-dest", default=None)
128+ checkout_parser.set_defaults(func=_cmd_checkout)
129+
130+ reconcile_parser = sous_commandes.add_parser(
131+ "reconcile", help="Réconcilie un dossier checkouté"
132+ )
133+ reconcile_parser.add_argument("dossier")
134+ reconcile_parser.add_argument("--local-dest", default=None)
135+ reconcile_parser.set_defaults(func=_cmd_reconcile)
136+
137+ return analyseur
138+
139+
140+def main(argv: list[str] | None = None) -> int:
141+ analyseur = construire_analyseur()
142+ args = analyseur.parse_args(argv)
143+ return args.func(args)
144+
145+
146+if __name__ == "__main__":
147+ raise SystemExit(main())
new file mode 100644
@@ -0,0 +1,147 @@
1+"""Commandes `regine checkout` / `regine reconcile` (cf. contracts/cli-checkout-reconcile.md).
2+
3+Façade fine : orchestre `regine_core.archive`, formatte le résultat, ne contient
4+aucune logique métier propre (Principe VI de la constitution).
5+"""
6+
7+from __future__ import annotations
8+
9+import argparse
10+import sys
11+from pathlib import Path
12+
13+from regine_core.archive.checkout import checkout as checkout_dossier
14+from regine_core.archive.manifest import ouvrir_ou_creer
15+from regine_core.archive.reconciliation import DecisionsUtilisateur, archiver, comparer
16+from regine_core.archive.verrou import DossierDejaVerrouilleError, lever
17+
18+
19+def _cmd_checkout(args: argparse.Namespace) -> int:
20+ dossier_archive = Path(args.dossier)
21+ dest_locale = Path(args.local_dest) if args.local_dest else Path.cwd() / dossier_archive.name
22+ formats = args.formats.split(",") if args.formats else None
23+
24+ try:
25+ snapshot = checkout_dossier(dossier_archive, dest_locale, formats=formats)
26+ except DossierDejaVerrouilleError as exc:
27+ print(f"Erreur : {exc}", file=sys.stderr)
28+ return 1
29+
30+ print(f"{len(snapshot.fichiers)} fichier(s) sortis vers {dest_locale}")
31+ snapshot.manifest.conn.close()
32+ return 0
33+
34+
35+def _afficher_point_avant_archive(rapport) -> None:
36+ print("Point avant archive :")
37+ for changement in rapport.changements:
38+ if changement.categorie == "deplacement":
39+ print(f" [déplacement] {changement.chemin_ancien} -> {changement.chemin}")
40+ else:
41+ print(f" [{changement.categorie}] {changement.chemin}")
42+ if not rapport.changements:
43+ print(" (aucun changement)")
44+
45+
46+def _cmd_reconcile(args: argparse.Namespace) -> int:
47+ dossier_archive = Path(args.dossier)
48+ dossier_local = Path(args.local_dest) if args.local_dest else Path.cwd() / dossier_archive.name
49+
50+ manifest = ouvrir_ou_creer(dossier_archive)
51+ # Reconstruit un ManifestSnapshot à partir du manifeste déjà sur l'archive
52+ # (le checkout d'origine peut avoir eu lieu dans une session précédente).
53+ from regine_core.archive.checkout import ( # noqa: PLC0415
54+ FichierManifesteEntry,
55+ ManifestSnapshot,
56+ )
57+
58+ lignes = manifest.conn.execute(
59+ "SELECT chemin_relatif, taille, hash_fichier_entier, hash_image_only, identifiant_perenne "
60+ "FROM fichiers"
61+ ).fetchall()
62+ fichiers = {
63+ ligne[0]: FichierManifesteEntry(
64+ chemin_relatif=ligne[0],
65+ taille=ligne[1],
66+ hash_fichier_entier=ligne[2],
67+ hash_image_only=ligne[3],
68+ identifiant_perenne=ligne[4],
69+ )
70+ for ligne in lignes
71+ }
72+ snapshot = ManifestSnapshot(
73+ dossier_archive=dossier_archive,
74+ dossier_local=dossier_local,
75+ fichiers=fichiers,
76+ formats=None,
77+ manifest=manifest,
78+ )
79+
80+ rapport = comparer(snapshot, dossier_local)
81+ _afficher_point_avant_archive(rapport)
82+
83+ if not rapport.changements:
84+ # Rien à réconcilier : la session se termine sans écriture, mais le verrou
85+ # doit être levé (FR-016 s'applique aussi à une réconciliation "à vide").
86+ lever(manifest)
87+ manifest.conn.close()
88+ return 0
89+
90+ decisions = DecisionsUtilisateur()
91+
92+ for anomalie in rapport.anomalies:
93+ reponse = (
94+ input(
95+ f"Anomalie sur {anomalie.chemin} : confirmer malgré tout (c) ou restaurer depuis "
96+ f"l'archive (r) ? [c/r] "
97+ )
98+ .strip()
99+ .lower()
100+ )
101+ decisions.resolutions_anomalies[str(anomalie.chemin)] = (
102+ "confirmer" if reponse == "c" else "restaurer"
103+ )
104+
105+ for nouveau in (c for c in rapport.changements if c.categorie == "nouveau"):
106+ question = f"Nouveau fichier {nouveau.chemin} : archiver (a) ou laisser local (l) ? [a/l] "
107+ reponse = input(question)
108+ if reponse.strip().lower() == "a":
109+ decisions.nouveaux_fichiers_a_archiver.add(str(nouveau.chemin))
110+
111+ confirmation = input("Confirmer l'écriture des changements normaux sur l'archive ? [o/n] ")
112+ decisions.confirmer_changements_normaux = confirmation.strip().lower() == "o"
113+
114+ archiver(snapshot, rapport, decisions)
115+ print("Réconciliation terminée.")
116+ manifest.conn.close()
117+ return 0
118+
119+
120+def construire_analyseur() -> argparse.ArgumentParser:
121+ analyseur = argparse.ArgumentParser(prog="regine")
122+ sous_commandes = analyseur.add_subparsers(dest="commande", required=True)
123+
124+ checkout_parser = sous_commandes.add_parser("checkout", help="Sort un dossier de l'archive")
125+ checkout_parser.add_argument("dossier")
126+ checkout_parser.add_argument("--formats", default=None)
127+ checkout_parser.add_argument("--local-dest", default=None)
128+ checkout_parser.set_defaults(func=_cmd_checkout)
129+
130+ reconcile_parser = sous_commandes.add_parser(
131+ "reconcile", help="Réconcilie un dossier checkouté"
132+ )
133+ reconcile_parser.add_argument("dossier")
134+ reconcile_parser.add_argument("--local-dest", default=None)
135+ reconcile_parser.set_defaults(func=_cmd_reconcile)
136+
137+ return analyseur
138+
139+
140+def main(argv: list[str] | None = None) -> int:
141+ analyseur = construire_analyseur()
142+ args = analyseur.parse_args(argv)
143+ return args.func(args)
144+
145+
146+if __name__ == "__main__":
147+ raise SystemExit(main())
added packages/regine-core/src/regine_core/archive/__init__.py +19 -0
new file mode 100644
@@ -0,0 +1,19 @@
1+"""Manifeste persistant, verrouillage, checkout et réconciliation d'un dossier de l'archive.
2+
3+Cf. specs/005-checkout-reconciliation. Dernier module de la bibliothèque centrale nommé
4+dans docs/interface-cli-gui-architecture.md.
5+"""
6+
7+from regine_core.archive.manifest import (
8+ ManifestHandle,
9+ VersionStructurelleNonSupporteeError,
10+ ouvrir_ou_creer,
11+)
12+from regine_core.archive.verrou import DossierDejaVerrouilleError
13+
14+__all__ = [
15+ "DossierDejaVerrouilleError",
16+ "ManifestHandle",
17+ "VersionStructurelleNonSupporteeError",
18+ "ouvrir_ou_creer",
19+]
new file mode 100644
@@ -0,0 +1,19 @@
1+"""Manifeste persistant, verrouillage, checkout et réconciliation d'un dossier de l'archive.
2+
3+Cf. specs/005-checkout-reconciliation. Dernier module de la bibliothèque centrale nommé
4+dans docs/interface-cli-gui-architecture.md.
5+"""
6+
7+from regine_core.archive.manifest import (
8+ ManifestHandle,
9+ VersionStructurelleNonSupporteeError,
10+ ouvrir_ou_creer,
11+)
12+from regine_core.archive.verrou import DossierDejaVerrouilleError
13+
14+__all__ = [
15+ "DossierDejaVerrouilleError",
16+ "ManifestHandle",
17+ "VersionStructurelleNonSupporteeError",
18+ "ouvrir_ou_creer",
19+]
added packages/regine-core/src/regine_core/archive/checkout.py +134 -0
new file mode 100644
@@ -0,0 +1,134 @@
1+"""Checkout d'un dossier de l'archive vers un espace de travail local.
2+
3+Cf. FR-001 à FR-005, FR-015, FR-019, FR-020 de specs/005-checkout-reconciliation.
4+"""
5+
6+from __future__ import annotations
7+
8+import shutil
9+from dataclasses import dataclass, field
10+from pathlib import Path
11+
12+from regine_core.archive import manifest as manifest_module
13+from regine_core.archive import verrou
14+from regine_core.archive.manifest import ManifestHandle
15+from regine_core.integrity.hash import empreinte
16+
17+
18+class IntegriteCopieError(Exception):
19+ """La vérification par empreinte d'une copie a échoué (FR-015)."""
20+
21+
22+@dataclass(frozen=True)
23+class FichierManifesteEntry:
24+ """Une ligne de la table `fichiers` du manifeste (cf. data-model.md)."""
25+
26+ chemin_relatif: str
27+ taille: int
28+ hash_fichier_entier: str
29+ hash_image_only: str | None
30+ identifiant_perenne: str | None = None
31+
32+
33+@dataclass
34+class ManifestSnapshot:
35+ """État de référence pris au checkout (FR-002), plus le contexte du checkout lui-même."""
36+
37+ dossier_archive: Path
38+ dossier_local: Path
39+ fichiers: dict[str, FichierManifesteEntry] = field(default_factory=dict)
40+ formats: list[str] | None = None
41+ manifest: ManifestHandle | None = None
42+
43+
44+def dans_perimetre(chemin_relatif: str, formats: list[str] | None) -> bool:
45+ """Un fichier est dans le périmètre d'un checkout (complet ou partiel par format).
46+
47+ La racine de sélection (fichiers directement sous le dossier principal, pas dans
48+ un sous-répertoire de format) est toujours incluse (FR-019).
49+ """
50+ if formats is None:
51+ return True
52+ parties = Path(chemin_relatif).parts
53+ if len(parties) <= 1:
54+ return True
55+ return parties[0] in formats
56+
57+
58+def _lister_fichiers_source(dossier_archive: Path, formats: list[str] | None) -> list[Path]:
59+ resultat = []
60+ for chemin in dossier_archive.rglob("*"):
61+ if not chemin.is_file():
62+ continue
63+ if chemin.name == manifest_module.NOM_FICHIER_MANIFESTE:
64+ continue
65+ relatif = str(chemin.relative_to(dossier_archive))
66+ if dans_perimetre(relatif, formats):
67+ resultat.append(chemin)
68+ return resultat
69+
70+
71+def _copier_verifie(source: Path, destination: Path) -> None:
72+ destination.parent.mkdir(parents=True, exist_ok=True)
73+ hash_source = empreinte(source).hash_fichier_entier
74+ shutil.copy2(source, destination)
75+ hash_destination = empreinte(destination).hash_fichier_entier
76+ if hash_source != hash_destination:
77+ raise IntegriteCopieError(f"Échec de vérification d'intégrité pour {destination}")
78+
79+
80+def checkout(
81+ dossier_archive: Path,
82+ dest_locale: Path,
83+ formats: list[str] | None = None,
84+) -> ManifestSnapshot:
85+ """Sort (checkout) un dossier de l'archive vers un espace de travail local.
86+
87+ Copie l'intégralité du dossier (parent + tous ses sous-dossiers, FR-001), ou un
88+ sous-ensemble de formats plus la racine de sélection (FR-019, `formats` non
89+ `None`). Enregistre un état de référence complet (FR-002) et verrouille le
90+ dossier (FR-003) avant de commencer. Lève `regine_core.archive.verrou.
91+ DossierDejaVerrouilleError` si le dossier est déjà verrouillé (FR-004).
92+ """
93+ manifest = manifest_module.ouvrir_ou_creer(dossier_archive)
94+ verrou.poser(manifest)
95+
96+ dest_locale.mkdir(parents=True, exist_ok=True)
97+ fichiers_source = _lister_fichiers_source(dossier_archive, formats)
98+
99+ entrees: dict[str, FichierManifesteEntry] = {}
100+ for source in fichiers_source:
101+ chemin_relatif = str(source.relative_to(dossier_archive))
102+ destination = dest_locale / chemin_relatif
103+
104+ _copier_verifie(source, destination)
105+
106+ emp = empreinte(source)
107+ entree = FichierManifesteEntry(
108+ chemin_relatif=chemin_relatif,
109+ taille=source.stat().st_size,
110+ hash_fichier_entier=emp.hash_fichier_entier,
111+ hash_image_only=emp.hash_image_only,
112+ )
113+ entrees[chemin_relatif] = entree
114+ manifest.conn.execute(
115+ "INSERT OR REPLACE INTO fichiers "
116+ "(chemin_relatif, taille, hash_fichier_entier, hash_image_only, identifiant_perenne) "
117+ "VALUES (?, ?, ?, ?, ?)",
118+ (
119+ entree.chemin_relatif,
120+ entree.taille,
121+ entree.hash_fichier_entier,
122+ entree.hash_image_only,
123+ entree.identifiant_perenne,
124+ ),
125+ )
126+ manifest.conn.commit()
127+
128+ return ManifestSnapshot(
129+ dossier_archive=dossier_archive,
130+ dossier_local=dest_locale,
131+ fichiers=entrees,
132+ formats=formats,
133+ manifest=manifest,
134+ )
new file mode 100644
@@ -0,0 +1,134 @@
1+"""Checkout d'un dossier de l'archive vers un espace de travail local.
2+
3+Cf. FR-001 à FR-005, FR-015, FR-019, FR-020 de specs/005-checkout-reconciliation.
4+"""
5+
6+from __future__ import annotations
7+
8+import shutil
9+from dataclasses import dataclass, field
10+from pathlib import Path
11+
12+from regine_core.archive import manifest as manifest_module
13+from regine_core.archive import verrou
14+from regine_core.archive.manifest import ManifestHandle
15+from regine_core.integrity.hash import empreinte
16+
17+
18+class IntegriteCopieError(Exception):
19+ """La vérification par empreinte d'une copie a échoué (FR-015)."""
20+
21+
22+@dataclass(frozen=True)
23+class FichierManifesteEntry:
24+ """Une ligne de la table `fichiers` du manifeste (cf. data-model.md)."""
25+
26+ chemin_relatif: str
27+ taille: int
28+ hash_fichier_entier: str
29+ hash_image_only: str | None
30+ identifiant_perenne: str | None = None
31+
32+
33+@dataclass
34+class ManifestSnapshot:
35+ """État de référence pris au checkout (FR-002), plus le contexte du checkout lui-même."""
36+
37+ dossier_archive: Path
38+ dossier_local: Path
39+ fichiers: dict[str, FichierManifesteEntry] = field(default_factory=dict)
40+ formats: list[str] | None = None
41+ manifest: ManifestHandle | None = None
42+
43+
44+def dans_perimetre(chemin_relatif: str, formats: list[str] | None) -> bool:
45+ """Un fichier est dans le périmètre d'un checkout (complet ou partiel par format).
46+
47+ La racine de sélection (fichiers directement sous le dossier principal, pas dans
48+ un sous-répertoire de format) est toujours incluse (FR-019).
49+ """
50+ if formats is None:
51+ return True
52+ parties = Path(chemin_relatif).parts
53+ if len(parties) <= 1:
54+ return True
55+ return parties[0] in formats
56+
57+
58+def _lister_fichiers_source(dossier_archive: Path, formats: list[str] | None) -> list[Path]:
59+ resultat = []
60+ for chemin in dossier_archive.rglob("*"):
61+ if not chemin.is_file():
62+ continue
63+ if chemin.name == manifest_module.NOM_FICHIER_MANIFESTE:
64+ continue
65+ relatif = str(chemin.relative_to(dossier_archive))
66+ if dans_perimetre(relatif, formats):
67+ resultat.append(chemin)
68+ return resultat
69+
70+
71+def _copier_verifie(source: Path, destination: Path) -> None:
72+ destination.parent.mkdir(parents=True, exist_ok=True)
73+ hash_source = empreinte(source).hash_fichier_entier
74+ shutil.copy2(source, destination)
75+ hash_destination = empreinte(destination).hash_fichier_entier
76+ if hash_source != hash_destination:
77+ raise IntegriteCopieError(f"Échec de vérification d'intégrité pour {destination}")
78+
79+
80+def checkout(
81+ dossier_archive: Path,
82+ dest_locale: Path,
83+ formats: list[str] | None = None,
84+) -> ManifestSnapshot:
85+ """Sort (checkout) un dossier de l'archive vers un espace de travail local.
86+
87+ Copie l'intégralité du dossier (parent + tous ses sous-dossiers, FR-001), ou un
88+ sous-ensemble de formats plus la racine de sélection (FR-019, `formats` non
89+ `None`). Enregistre un état de référence complet (FR-002) et verrouille le
90+ dossier (FR-003) avant de commencer. Lève `regine_core.archive.verrou.
91+ DossierDejaVerrouilleError` si le dossier est déjà verrouillé (FR-004).
92+ """
93+ manifest = manifest_module.ouvrir_ou_creer(dossier_archive)
94+ verrou.poser(manifest)
95+
96+ dest_locale.mkdir(parents=True, exist_ok=True)
97+ fichiers_source = _lister_fichiers_source(dossier_archive, formats)
98+
99+ entrees: dict[str, FichierManifesteEntry] = {}
100+ for source in fichiers_source:
101+ chemin_relatif = str(source.relative_to(dossier_archive))
102+ destination = dest_locale / chemin_relatif
103+
104+ _copier_verifie(source, destination)
105+
106+ emp = empreinte(source)
107+ entree = FichierManifesteEntry(
108+ chemin_relatif=chemin_relatif,
109+ taille=source.stat().st_size,
110+ hash_fichier_entier=emp.hash_fichier_entier,
111+ hash_image_only=emp.hash_image_only,
112+ )
113+ entrees[chemin_relatif] = entree
114+ manifest.conn.execute(
115+ "INSERT OR REPLACE INTO fichiers "
116+ "(chemin_relatif, taille, hash_fichier_entier, hash_image_only, identifiant_perenne) "
117+ "VALUES (?, ?, ?, ?, ?)",
118+ (
119+ entree.chemin_relatif,
120+ entree.taille,
121+ entree.hash_fichier_entier,
122+ entree.hash_image_only,
123+ entree.identifiant_perenne,
124+ ),
125+ )
126+ manifest.conn.commit()
127+
128+ return ManifestSnapshot(
129+ dossier_archive=dossier_archive,
130+ dossier_local=dest_locale,
131+ fichiers=entrees,
132+ formats=formats,
133+ manifest=manifest,
134+ )
added packages/regine-core/src/regine_core/archive/manifest.py +110 -0
new file mode 100644
@@ -0,0 +1,110 @@
1+"""Manifeste persistant par dossier principal (archive).
2+
3+Un fichier SQLite unique à la racine de chaque dossier principal (dossier simple, ou
4+dossier parent avec tous ses sous-dossiers) — cf. constitution § Workflow d'archivage
5+et specs/005-checkout-reconciliation research.md § 1/§ 2.
6+"""
7+
8+from __future__ import annotations
9+
10+import sqlite3
11+from dataclasses import dataclass
12+from pathlib import Path
13+
14+#: Identifiant Régine pour PRAGMA application_id — partagé avec regine_core.config.db
15+#: (même format Régine, cf. constitution § Workflow d'archivage).
16+APPLICATION_ID = 0x52454749
17+
18+#: Version de schéma courante, encodée `structurel * 1000 + additif` (cf. research.md § 2).
19+STRUCTUREL_COURANT = 1
20+ADDITIF_COURANT = 0
21+
22+#: Nom du fichier manifeste à la racine d'un dossier principal.
23+NOM_FICHIER_MANIFESTE = ".regine-manifest.sqlite3"
24+
25+_CREATE_FICHIERS = """
26+CREATE TABLE IF NOT EXISTS fichiers (
27+ chemin_relatif TEXT PRIMARY KEY,
28+ taille INTEGER NOT NULL,
29+ hash_fichier_entier TEXT NOT NULL,
30+ hash_image_only TEXT,
31+ identifiant_perenne TEXT
32+)
33+"""
34+
35+_CREATE_VERROU = """
36+CREATE TABLE IF NOT EXISTS verrou (
37+ id INTEGER PRIMARY KEY CHECK (id = 1),
38+ verrouille INTEGER NOT NULL DEFAULT 0,
39+ identifiant_session TEXT,
40+ horodatage TEXT
41+)
42+"""
43+
44+
45+class VersionStructurelleNonSupporteeError(Exception):
46+ """Le manifeste a été écrit par une version de Régine dont le format structurel
47+ n'est pas compris par cette version du code (FR-018)."""
48+
49+
50+class FormatManifesteInvalideError(Exception):
51+ """Le fichier n'est pas un manifeste Régine (application_id différent)."""
52+
53+
54+@dataclass
55+class ManifestHandle:
56+ """Connexion ouverte vers le manifeste d'un dossier principal."""
57+
58+ conn: sqlite3.Connection
59+ dossier: Path
60+
61+
62+def _encoder_version(structurel: int, additif: int) -> int:
63+ return structurel * 1000 + additif
64+
65+
66+def _decoder_version(valeur: int) -> tuple[int, int]:
67+ return divmod(valeur, 1000)
68+
69+
70+def ouvrir_ou_creer(dossier: Path) -> ManifestHandle:
71+ """Ouvre le manifeste d'un dossier principal, en l'initialisant s'il n'existe pas.
72+
73+ Refuse explicitement (`VersionStructurelleNonSupporteeError`) d'ouvrir un
74+ manifeste dont la version structurelle est plus récente que ce que le code sait
75+ lire (FR-018), plutôt que de l'interpréter à tort. Une évolution additive est
76+ tolérée silencieusement.
77+ """
78+ chemin_db = dossier / NOM_FICHIER_MANIFESTE
79+ chemin_db.parent.mkdir(parents=True, exist_ok=True)
80+ conn = sqlite3.connect(chemin_db)
81+ conn.execute("PRAGMA foreign_keys = ON")
82+
83+ application_id_actuel = conn.execute("PRAGMA application_id").fetchone()[0]
84+ if application_id_actuel == 0:
85+ conn.execute(f"PRAGMA application_id = {APPLICATION_ID}")
86+ conn.execute(
87+ f"PRAGMA user_version = {_encoder_version(STRUCTUREL_COURANT, ADDITIF_COURANT)}"
88+ )
89+ elif application_id_actuel != APPLICATION_ID:
90+ conn.close()
91+ raise FormatManifesteInvalideError(
92+ f"{chemin_db} n'est pas un manifeste Régine "
93+ f"(application_id={application_id_actuel:#x}, attendu {APPLICATION_ID:#x})"
94+ )
95+ else:
96+ version_stockee = conn.execute("PRAGMA user_version").fetchone()[0]
97+ structurel_stocke, _ = _decoder_version(version_stockee)
98+ if structurel_stocke > STRUCTUREL_COURANT:
99+ conn.close()
100+ raise VersionStructurelleNonSupporteeError(
101+ f"{chemin_db} a un format structurel ({structurel_stocke}) plus récent "
102+ f"que celui supporté par cette version de Régine ({STRUCTUREL_COURANT})"
103+ )
104+
105+ conn.execute(_CREATE_FICHIERS)
106+ conn.execute(_CREATE_VERROU)
107+ conn.execute("INSERT OR IGNORE INTO verrou (id, verrouille) VALUES (1, 0)")
108+ conn.commit()
109+
110+ return ManifestHandle(conn=conn, dossier=dossier)
new file mode 100644
@@ -0,0 +1,110 @@
1+"""Manifeste persistant par dossier principal (archive).
2+
3+Un fichier SQLite unique à la racine de chaque dossier principal (dossier simple, ou
4+dossier parent avec tous ses sous-dossiers) — cf. constitution § Workflow d'archivage
5+et specs/005-checkout-reconciliation research.md § 1/§ 2.
6+"""
7+
8+from __future__ import annotations
9+
10+import sqlite3
11+from dataclasses import dataclass
12+from pathlib import Path
13+
14+#: Identifiant Régine pour PRAGMA application_id — partagé avec regine_core.config.db
15+#: (même format Régine, cf. constitution § Workflow d'archivage).
16+APPLICATION_ID = 0x52454749
17+
18+#: Version de schéma courante, encodée `structurel * 1000 + additif` (cf. research.md § 2).
19+STRUCTUREL_COURANT = 1
20+ADDITIF_COURANT = 0
21+
22+#: Nom du fichier manifeste à la racine d'un dossier principal.
23+NOM_FICHIER_MANIFESTE = ".regine-manifest.sqlite3"
24+
25+_CREATE_FICHIERS = """
26+CREATE TABLE IF NOT EXISTS fichiers (
27+ chemin_relatif TEXT PRIMARY KEY,
28+ taille INTEGER NOT NULL,
29+ hash_fichier_entier TEXT NOT NULL,
30+ hash_image_only TEXT,
31+ identifiant_perenne TEXT
32+)
33+"""
34+
35+_CREATE_VERROU = """
36+CREATE TABLE IF NOT EXISTS verrou (
37+ id INTEGER PRIMARY KEY CHECK (id = 1),
38+ verrouille INTEGER NOT NULL DEFAULT 0,
39+ identifiant_session TEXT,
40+ horodatage TEXT
41+)
42+"""
43+
44+
45+class VersionStructurelleNonSupporteeError(Exception):
46+ """Le manifeste a été écrit par une version de Régine dont le format structurel
47+ n'est pas compris par cette version du code (FR-018)."""
48+
49+
50+class FormatManifesteInvalideError(Exception):
51+ """Le fichier n'est pas un manifeste Régine (application_id différent)."""
52+
53+
54+@dataclass
55+class ManifestHandle:
56+ """Connexion ouverte vers le manifeste d'un dossier principal."""
57+
58+ conn: sqlite3.Connection
59+ dossier: Path
60+
61+
62+def _encoder_version(structurel: int, additif: int) -> int:
63+ return structurel * 1000 + additif
64+
65+
66+def _decoder_version(valeur: int) -> tuple[int, int]:
67+ return divmod(valeur, 1000)
68+
69+
70+def ouvrir_ou_creer(dossier: Path) -> ManifestHandle:
71+ """Ouvre le manifeste d'un dossier principal, en l'initialisant s'il n'existe pas.
72+
73+ Refuse explicitement (`VersionStructurelleNonSupporteeError`) d'ouvrir un
74+ manifeste dont la version structurelle est plus récente que ce que le code sait
75+ lire (FR-018), plutôt que de l'interpréter à tort. Une évolution additive est
76+ tolérée silencieusement.
77+ """
78+ chemin_db = dossier / NOM_FICHIER_MANIFESTE
79+ chemin_db.parent.mkdir(parents=True, exist_ok=True)
80+ conn = sqlite3.connect(chemin_db)
81+ conn.execute("PRAGMA foreign_keys = ON")
82+
83+ application_id_actuel = conn.execute("PRAGMA application_id").fetchone()[0]
84+ if application_id_actuel == 0:
85+ conn.execute(f"PRAGMA application_id = {APPLICATION_ID}")
86+ conn.execute(
87+ f"PRAGMA user_version = {_encoder_version(STRUCTUREL_COURANT, ADDITIF_COURANT)}"
88+ )
89+ elif application_id_actuel != APPLICATION_ID:
90+ conn.close()
91+ raise FormatManifesteInvalideError(
92+ f"{chemin_db} n'est pas un manifeste Régine "
93+ f"(application_id={application_id_actuel:#x}, attendu {APPLICATION_ID:#x})"
94+ )
95+ else:
96+ version_stockee = conn.execute("PRAGMA user_version").fetchone()[0]
97+ structurel_stocke, _ = _decoder_version(version_stockee)
98+ if structurel_stocke > STRUCTUREL_COURANT:
99+ conn.close()
100+ raise VersionStructurelleNonSupporteeError(
101+ f"{chemin_db} a un format structurel ({structurel_stocke}) plus récent "
102+ f"que celui supporté par cette version de Régine ({STRUCTUREL_COURANT})"
103+ )
104+
105+ conn.execute(_CREATE_FICHIERS)
106+ conn.execute(_CREATE_VERROU)
107+ conn.execute("INSERT OR IGNORE INTO verrou (id, verrouille) VALUES (1, 0)")
108+ conn.commit()
109+
110+ return ManifestHandle(conn=conn, dossier=dossier)
added packages/regine-core/src/regine_core/archive/reconciliation.py +242 -0
new file mode 100644
@@ -0,0 +1,242 @@
1+"""Réconciliation d'une copie de travail avec le manifeste de référence (FR-006 à FR-016)."""
2+
3+from __future__ import annotations
4+
5+import shutil
6+from dataclasses import dataclass, field
7+from pathlib import Path
8+
9+from regine_core.archive.checkout import (
10+ FichierManifesteEntry,
11+ IntegriteCopieError,
12+ ManifestSnapshot,
13+ dans_perimetre,
14+)
15+from regine_core.archive.verrou import lever
16+from regine_core.integrity.anomalie import est_maitre_modifie
17+from regine_core.integrity.hash import Empreinte, empreinte
18+
19+#: Extensions de sidecars : jamais soumises à la détection d'anomalie (FR-006), un
20+#: changement de sidecar est toujours un cas normal.
21+EXTENSIONS_SIDECAR = {"xmp", "dop", "acr"}
22+
23+
24+@dataclass(frozen=True)
25+class ChangementClasse:
26+ """Un changement détecté à la réconciliation, classé selon FR-006."""
27+
28+ chemin: Path
29+ categorie: str # "normal" | "anomalie" | "deplacement" | "suppression" | "nouveau"
30+ chemin_ancien: Path | None = None
31+
32+
33+@dataclass(frozen=True)
34+class RapportReconciliation:
35+ """Résultat de `comparer` — le « point avant archive » (FR-013)."""
36+
37+ changements: list[ChangementClasse]
38+ anomalies: list[ChangementClasse]
39+
40+
41+@dataclass
42+class DecisionsUtilisateur:
43+ """Décisions explicites de l'utilisateur sur un `RapportReconciliation` (Principe II/V)."""
44+
45+ confirmer_changements_normaux: bool = False
46+ # chemin -> "confirmer" | "restaurer"
47+ resolutions_anomalies: dict[str, str] = field(default_factory=dict)
48+ nouveaux_fichiers_a_archiver: set[str] = field(default_factory=set)
49+
50+
51+def _scanner_copie_locale(copie_locale: Path) -> dict[str, FichierManifesteEntry]:
52+ resultat: dict[str, FichierManifesteEntry] = {}
53+ for chemin in copie_locale.rglob("*"):
54+ if not chemin.is_file():
55+ continue
56+ if chemin.name.startswith(".regine-manifest"):
57+ continue
58+ relatif = str(chemin.relative_to(copie_locale))
59+ emp = empreinte(chemin)
60+ resultat[relatif] = FichierManifesteEntry(
61+ chemin_relatif=relatif,
62+ taille=chemin.stat().st_size,
63+ hash_fichier_entier=emp.hash_fichier_entier,
64+ hash_image_only=emp.hash_image_only,
65+ )
66+ return resultat
67+
68+
69+def _cle_deplacement(entree: FichierManifesteEntry) -> str:
70+ """Empreinte pertinente pour détecter un déplacement (FR-009/010) : image-only
71+ quand disponible (stable à travers une édition de métadonnées), sinon fichier entier."""
72+ return entree.hash_image_only or entree.hash_fichier_entier
73+
74+
75+def comparer(snapshot: ManifestSnapshot, copie_locale: Path) -> RapportReconciliation:
76+ """Compare la copie de travail à l'état de référence du checkout (FR-006).
77+
78+ Classe chaque fichier en : inchangé (omis du rapport), normal (sidecar, ou
79+ métadonnées seules sur DNG/TIFF/JPEG), anomalie (fichier maître modifié),
80+ déplacement (contenu inchangé, chemin différent — fichier ou dossier entier),
81+ suppression, ou nouveau fichier sans correspondance.
82+ """
83+ fichiers_locaux = _scanner_copie_locale(copie_locale)
84+
85+ chemins_manifeste = {c for c in snapshot.fichiers if dans_perimetre(c, snapshot.formats)}
86+ chemins_locaux = set(fichiers_locaux)
87+
88+ chemins_communs = chemins_manifeste & chemins_locaux
89+ chemins_disparus = chemins_manifeste - chemins_locaux
90+ chemins_nouveaux = chemins_locaux - chemins_manifeste
91+
92+ changements: list[ChangementClasse] = []
93+
94+ for chemin in chemins_communs:
95+ ref = snapshot.fichiers[chemin]
96+ actuel = fichiers_locaux[chemin]
97+ if ref.hash_fichier_entier == actuel.hash_fichier_entier:
98+ continue # inchangé : rien à signaler
99+
100+ extension = Path(chemin).suffix.lstrip(".").lower()
101+ if extension in EXTENSIONS_SIDECAR:
102+ changements.append(ChangementClasse(chemin=Path(chemin), categorie="normal"))
103+ continue
104+
105+ ref_empreinte = Empreinte(ref.hash_fichier_entier, ref.hash_image_only)
106+ actuel_empreinte = Empreinte(actuel.hash_fichier_entier, actuel.hash_image_only)
107+ if est_maitre_modifie(extension, ref_empreinte, actuel_empreinte):
108+ changements.append(ChangementClasse(chemin=Path(chemin), categorie="anomalie"))
109+ else:
110+ changements.append(ChangementClasse(chemin=Path(chemin), categorie="normal"))
111+
112+ # Détection de déplacement par contenu, sur les chemins disparus/nouveaux restants.
113+ index_disparus = {_cle_deplacement(snapshot.fichiers[c]): c for c in chemins_disparus}
114+ index_nouveaux = {_cle_deplacement(fichiers_locaux[c]): c for c in chemins_nouveaux}
115+ cles_deplacees = set(index_disparus) & set(index_nouveaux)
116+
117+ for cle in cles_deplacees:
118+ ancien = index_disparus[cle]
119+ nouveau = index_nouveaux[cle]
120+ changements.append(
121+ ChangementClasse(
122+ chemin=Path(nouveau), categorie="deplacement", chemin_ancien=Path(ancien)
123+ )
124+ )
125+ chemins_disparus.discard(ancien)
126+ chemins_nouveaux.discard(nouveau)
127+
128+ for chemin in chemins_disparus:
129+ changements.append(ChangementClasse(chemin=Path(chemin), categorie="suppression"))
130+
131+ for chemin in chemins_nouveaux:
132+ changements.append(ChangementClasse(chemin=Path(chemin), categorie="nouveau"))
133+
134+ anomalies = [c for c in changements if c.categorie == "anomalie"]
135+ return RapportReconciliation(changements=changements, anomalies=anomalies)
136+
137+
138+def _copier_verifie(source: Path, destination: Path) -> None:
139+ destination.parent.mkdir(parents=True, exist_ok=True)
140+ hash_source = empreinte(source).hash_fichier_entier
141+ shutil.copy2(source, destination)
142+ if empreinte(destination).hash_fichier_entier != hash_source:
143+ raise IntegriteCopieError(f"Échec de vérification d'intégrité pour {destination}")
144+
145+
146+def _maj_manifeste(snapshot: ManifestSnapshot, chemin_relatif: str) -> None:
147+ chemin_local = snapshot.dossier_local / chemin_relatif
148+ if not chemin_local.exists():
149+ snapshot.manifest.conn.execute(
150+ "DELETE FROM fichiers WHERE chemin_relatif = ?", (chemin_relatif,)
151+ )
152+ snapshot.manifest.conn.commit()
153+ return
154+ emp = empreinte(chemin_local)
155+ snapshot.manifest.conn.execute(
156+ "INSERT OR REPLACE INTO fichiers "
157+ "(chemin_relatif, taille, hash_fichier_entier, hash_image_only, identifiant_perenne) "
158+ "VALUES (?, ?, ?, ?, NULL)",
159+ (chemin_relatif, chemin_local.stat().st_size, emp.hash_fichier_entier, emp.hash_image_only),
160+ )
161+ snapshot.manifest.conn.commit()
162+
163+
164+def _appliquer(snapshot: ManifestSnapshot, changement: ChangementClasse) -> None:
165+ if changement.categorie == "deplacement":
166+ ancien_chemin_archive = snapshot.dossier_archive / changement.chemin_ancien # type: ignore[arg-type]
167+ nouveau_chemin_archive = snapshot.dossier_archive / changement.chemin
168+ nouveau_chemin_archive.parent.mkdir(parents=True, exist_ok=True)
169+ if ancien_chemin_archive.exists():
170+ ancien_chemin_archive.rename(nouveau_chemin_archive)
171+ snapshot.manifest.conn.execute(
172+ "DELETE FROM fichiers WHERE chemin_relatif = ?", (str(changement.chemin_ancien),)
173+ )
174+ snapshot.manifest.conn.commit()
175+ _maj_manifeste(snapshot, str(changement.chemin))
176+ return
177+
178+ chemin_local = snapshot.dossier_local / changement.chemin
179+ chemin_archive = snapshot.dossier_archive / changement.chemin
180+ _copier_verifie(chemin_local, chemin_archive)
181+ _maj_manifeste(snapshot, str(changement.chemin))
182+
183+
184+def _restaurer(snapshot: ManifestSnapshot, changement: ChangementClasse) -> None:
185+ source = snapshot.dossier_archive / changement.chemin
186+ destination = snapshot.dossier_local / changement.chemin
187+ _copier_verifie(source, destination)
188+
189+
190+def _supprimer(snapshot: ManifestSnapshot, changement: ChangementClasse) -> None:
191+ chemin_archive = snapshot.dossier_archive / changement.chemin
192+ if chemin_archive.exists():
193+ chemin_archive.unlink()
194+ snapshot.manifest.conn.execute(
195+ "DELETE FROM fichiers WHERE chemin_relatif = ?", (str(changement.chemin),)
196+ )
197+ snapshot.manifest.conn.commit()
198+
199+
200+def archiver(
201+ snapshot: ManifestSnapshot,
202+ rapport: RapportReconciliation,
203+ decisions: DecisionsUtilisateur,
204+) -> None:
205+ """Écrit sur l'archive les seuls changements couverts par `decisions` (FR-013/014).
206+
207+ Ne DOIT jamais être appelée sans qu'un point avant archive ait été présenté et
208+ confirmé côté appelant. Vérifie chaque transfert par empreinte (FR-015). Lève le
209+ verrou (FR-016) uniquement une fois tous les changements du rapport traités
210+ (confirmés, restaurés, ou nouveaux fichiers explicitement laissés en local).
211+ """
212+ tout_traite = True
213+
214+ for changement in rapport.changements:
215+ chemin_str = str(changement.chemin)
216+
217+ if changement.categorie in ("normal", "deplacement", "suppression"):
218+ if not decisions.confirmer_changements_normaux:
219+ tout_traite = False
220+ continue
221+ if changement.categorie == "suppression":
222+ _supprimer(snapshot, changement)
223+ else:
224+ _appliquer(snapshot, changement)
225+
226+ elif changement.categorie == "anomalie":
227+ resolution = decisions.resolutions_anomalies.get(chemin_str)
228+ if resolution == "confirmer":
229+ _appliquer(snapshot, changement)
230+ elif resolution == "restaurer":
231+ _restaurer(snapshot, changement)
232+ _maj_manifeste(snapshot, chemin_str)
233+ else:
234+ tout_traite = False
235+
236+ elif changement.categorie == "nouveau":
237+ if chemin_str in decisions.nouveaux_fichiers_a_archiver:
238+ _appliquer(snapshot, changement)
239+ # Laissé en local : ni erreur ni blocage du verrou, c'est un choix valide (FR-012).
240+
241+ if tout_traite:
242+ lever(snapshot.manifest)
new file mode 100644
@@ -0,0 +1,242 @@
1+"""Réconciliation d'une copie de travail avec le manifeste de référence (FR-006 à FR-016)."""
2+
3+from __future__ import annotations
4+
5+import shutil
6+from dataclasses import dataclass, field
7+from pathlib import Path
8+
9+from regine_core.archive.checkout import (
10+ FichierManifesteEntry,
11+ IntegriteCopieError,
12+ ManifestSnapshot,
13+ dans_perimetre,
14+)
15+from regine_core.archive.verrou import lever
16+from regine_core.integrity.anomalie import est_maitre_modifie
17+from regine_core.integrity.hash import Empreinte, empreinte
18+
19+#: Extensions de sidecars : jamais soumises à la détection d'anomalie (FR-006), un
20+#: changement de sidecar est toujours un cas normal.
21+EXTENSIONS_SIDECAR = {"xmp", "dop", "acr"}
22+
23+
24+@dataclass(frozen=True)
25+class ChangementClasse:
26+ """Un changement détecté à la réconciliation, classé selon FR-006."""
27+
28+ chemin: Path
29+ categorie: str # "normal" | "anomalie" | "deplacement" | "suppression" | "nouveau"
30+ chemin_ancien: Path | None = None
31+
32+
33+@dataclass(frozen=True)
34+class RapportReconciliation:
35+ """Résultat de `comparer` — le « point avant archive » (FR-013)."""
36+
37+ changements: list[ChangementClasse]
38+ anomalies: list[ChangementClasse]
39+
40+
41+@dataclass
42+class DecisionsUtilisateur:
43+ """Décisions explicites de l'utilisateur sur un `RapportReconciliation` (Principe II/V)."""
44+
45+ confirmer_changements_normaux: bool = False
46+ # chemin -> "confirmer" | "restaurer"
47+ resolutions_anomalies: dict[str, str] = field(default_factory=dict)
48+ nouveaux_fichiers_a_archiver: set[str] = field(default_factory=set)
49+
50+
51+def _scanner_copie_locale(copie_locale: Path) -> dict[str, FichierManifesteEntry]:
52+ resultat: dict[str, FichierManifesteEntry] = {}
53+ for chemin in copie_locale.rglob("*"):
54+ if not chemin.is_file():
55+ continue
56+ if chemin.name.startswith(".regine-manifest"):
57+ continue
58+ relatif = str(chemin.relative_to(copie_locale))
59+ emp = empreinte(chemin)
60+ resultat[relatif] = FichierManifesteEntry(
61+ chemin_relatif=relatif,
62+ taille=chemin.stat().st_size,
63+ hash_fichier_entier=emp.hash_fichier_entier,
64+ hash_image_only=emp.hash_image_only,
65+ )
66+ return resultat
67+
68+
69+def _cle_deplacement(entree: FichierManifesteEntry) -> str:
70+ """Empreinte pertinente pour détecter un déplacement (FR-009/010) : image-only
71+ quand disponible (stable à travers une édition de métadonnées), sinon fichier entier."""
72+ return entree.hash_image_only or entree.hash_fichier_entier
73+
74+
75+def comparer(snapshot: ManifestSnapshot, copie_locale: Path) -> RapportReconciliation:
76+ """Compare la copie de travail à l'état de référence du checkout (FR-006).
77+
78+ Classe chaque fichier en : inchangé (omis du rapport), normal (sidecar, ou
79+ métadonnées seules sur DNG/TIFF/JPEG), anomalie (fichier maître modifié),
80+ déplacement (contenu inchangé, chemin différent — fichier ou dossier entier),
81+ suppression, ou nouveau fichier sans correspondance.
82+ """
83+ fichiers_locaux = _scanner_copie_locale(copie_locale)
84+
85+ chemins_manifeste = {c for c in snapshot.fichiers if dans_perimetre(c, snapshot.formats)}
86+ chemins_locaux = set(fichiers_locaux)
87+
88+ chemins_communs = chemins_manifeste & chemins_locaux
89+ chemins_disparus = chemins_manifeste - chemins_locaux
90+ chemins_nouveaux = chemins_locaux - chemins_manifeste
91+
92+ changements: list[ChangementClasse] = []
93+
94+ for chemin in chemins_communs:
95+ ref = snapshot.fichiers[chemin]
96+ actuel = fichiers_locaux[chemin]
97+ if ref.hash_fichier_entier == actuel.hash_fichier_entier:
98+ continue # inchangé : rien à signaler
99+
100+ extension = Path(chemin).suffix.lstrip(".").lower()
101+ if extension in EXTENSIONS_SIDECAR:
102+ changements.append(ChangementClasse(chemin=Path(chemin), categorie="normal"))
103+ continue
104+
105+ ref_empreinte = Empreinte(ref.hash_fichier_entier, ref.hash_image_only)
106+ actuel_empreinte = Empreinte(actuel.hash_fichier_entier, actuel.hash_image_only)
107+ if est_maitre_modifie(extension, ref_empreinte, actuel_empreinte):
108+ changements.append(ChangementClasse(chemin=Path(chemin), categorie="anomalie"))
109+ else:
110+ changements.append(ChangementClasse(chemin=Path(chemin), categorie="normal"))
111+
112+ # Détection de déplacement par contenu, sur les chemins disparus/nouveaux restants.
113+ index_disparus = {_cle_deplacement(snapshot.fichiers[c]): c for c in chemins_disparus}
114+ index_nouveaux = {_cle_deplacement(fichiers_locaux[c]): c for c in chemins_nouveaux}
115+ cles_deplacees = set(index_disparus) & set(index_nouveaux)
116+
117+ for cle in cles_deplacees:
118+ ancien = index_disparus[cle]
119+ nouveau = index_nouveaux[cle]
120+ changements.append(
121+ ChangementClasse(
122+ chemin=Path(nouveau), categorie="deplacement", chemin_ancien=Path(ancien)
123+ )
124+ )
125+ chemins_disparus.discard(ancien)
126+ chemins_nouveaux.discard(nouveau)
127+
128+ for chemin in chemins_disparus:
129+ changements.append(ChangementClasse(chemin=Path(chemin), categorie="suppression"))
130+
131+ for chemin in chemins_nouveaux:
132+ changements.append(ChangementClasse(chemin=Path(chemin), categorie="nouveau"))
133+
134+ anomalies = [c for c in changements if c.categorie == "anomalie"]
135+ return RapportReconciliation(changements=changements, anomalies=anomalies)
136+
137+
138+def _copier_verifie(source: Path, destination: Path) -> None:
139+ destination.parent.mkdir(parents=True, exist_ok=True)
140+ hash_source = empreinte(source).hash_fichier_entier
141+ shutil.copy2(source, destination)
142+ if empreinte(destination).hash_fichier_entier != hash_source:
143+ raise IntegriteCopieError(f"Échec de vérification d'intégrité pour {destination}")
144+
145+
146+def _maj_manifeste(snapshot: ManifestSnapshot, chemin_relatif: str) -> None:
147+ chemin_local = snapshot.dossier_local / chemin_relatif
148+ if not chemin_local.exists():
149+ snapshot.manifest.conn.execute(
150+ "DELETE FROM fichiers WHERE chemin_relatif = ?", (chemin_relatif,)
151+ )
152+ snapshot.manifest.conn.commit()
153+ return
154+ emp = empreinte(chemin_local)
155+ snapshot.manifest.conn.execute(
156+ "INSERT OR REPLACE INTO fichiers "
157+ "(chemin_relatif, taille, hash_fichier_entier, hash_image_only, identifiant_perenne) "
158+ "VALUES (?, ?, ?, ?, NULL)",
159+ (chemin_relatif, chemin_local.stat().st_size, emp.hash_fichier_entier, emp.hash_image_only),
160+ )
161+ snapshot.manifest.conn.commit()
162+
163+
164+def _appliquer(snapshot: ManifestSnapshot, changement: ChangementClasse) -> None:
165+ if changement.categorie == "deplacement":
166+ ancien_chemin_archive = snapshot.dossier_archive / changement.chemin_ancien # type: ignore[arg-type]
167+ nouveau_chemin_archive = snapshot.dossier_archive / changement.chemin
168+ nouveau_chemin_archive.parent.mkdir(parents=True, exist_ok=True)
169+ if ancien_chemin_archive.exists():
170+ ancien_chemin_archive.rename(nouveau_chemin_archive)
171+ snapshot.manifest.conn.execute(
172+ "DELETE FROM fichiers WHERE chemin_relatif = ?", (str(changement.chemin_ancien),)
173+ )
174+ snapshot.manifest.conn.commit()
175+ _maj_manifeste(snapshot, str(changement.chemin))
176+ return
177+
178+ chemin_local = snapshot.dossier_local / changement.chemin
179+ chemin_archive = snapshot.dossier_archive / changement.chemin
180+ _copier_verifie(chemin_local, chemin_archive)
181+ _maj_manifeste(snapshot, str(changement.chemin))
182+
183+
184+def _restaurer(snapshot: ManifestSnapshot, changement: ChangementClasse) -> None:
185+ source = snapshot.dossier_archive / changement.chemin
186+ destination = snapshot.dossier_local / changement.chemin
187+ _copier_verifie(source, destination)
188+
189+
190+def _supprimer(snapshot: ManifestSnapshot, changement: ChangementClasse) -> None:
191+ chemin_archive = snapshot.dossier_archive / changement.chemin
192+ if chemin_archive.exists():
193+ chemin_archive.unlink()
194+ snapshot.manifest.conn.execute(
195+ "DELETE FROM fichiers WHERE chemin_relatif = ?", (str(changement.chemin),)
196+ )
197+ snapshot.manifest.conn.commit()
198+
199+
200+def archiver(
201+ snapshot: ManifestSnapshot,
202+ rapport: RapportReconciliation,
203+ decisions: DecisionsUtilisateur,
204+) -> None:
205+ """Écrit sur l'archive les seuls changements couverts par `decisions` (FR-013/014).
206+
207+ Ne DOIT jamais être appelée sans qu'un point avant archive ait été présenté et
208+ confirmé côté appelant. Vérifie chaque transfert par empreinte (FR-015). Lève le
209+ verrou (FR-016) uniquement une fois tous les changements du rapport traités
210+ (confirmés, restaurés, ou nouveaux fichiers explicitement laissés en local).
211+ """
212+ tout_traite = True
213+
214+ for changement in rapport.changements:
215+ chemin_str = str(changement.chemin)
216+
217+ if changement.categorie in ("normal", "deplacement", "suppression"):
218+ if not decisions.confirmer_changements_normaux:
219+ tout_traite = False
220+ continue
221+ if changement.categorie == "suppression":
222+ _supprimer(snapshot, changement)
223+ else:
224+ _appliquer(snapshot, changement)
225+
226+ elif changement.categorie == "anomalie":
227+ resolution = decisions.resolutions_anomalies.get(chemin_str)
228+ if resolution == "confirmer":
229+ _appliquer(snapshot, changement)
230+ elif resolution == "restaurer":
231+ _restaurer(snapshot, changement)
232+ _maj_manifeste(snapshot, chemin_str)
233+ else:
234+ tout_traite = False
235+
236+ elif changement.categorie == "nouveau":
237+ if chemin_str in decisions.nouveaux_fichiers_a_archiver:
238+ _appliquer(snapshot, changement)
239+ # Laissé en local : ni erreur ni blocage du verrou, c'est un choix valide (FR-012).
240+
241+ if tout_traite:
242+ lever(snapshot.manifest)
added packages/regine-core/src/regine_core/archive/verrou.py +43 -0
new file mode 100644
@@ -0,0 +1,43 @@
1+"""Verrouillage d'un dossier pendant un checkout en cours (FR-003/004/016).
2+
3+Protection applicative de bonne foi entre instances de Régine sur un partage réseau
4+ordinaire — pas une garantie distribuée absolue (cf. research.md § 3).
5+"""
6+
7+from __future__ import annotations
8+
9+from datetime import UTC, datetime
10+
11+from regine_core.archive.manifest import ManifestHandle
12+
13+
14+class DossierDejaVerrouilleError(Exception):
15+ """Le dossier est déjà verrouillé par un checkout en cours (FR-004)."""
16+
17+
18+def poser(manifest: ManifestHandle, identifiant_session: str = "default") -> None:
19+ """Verrouille le dossier ; lève `DossierDejaVerrouilleError` s'il l'est déjà."""
20+ if verifier(manifest):
21+ raise DossierDejaVerrouilleError(
22+ f"{manifest.dossier} est déjà verrouillé (checkout en cours)"
23+ )
24+ manifest.conn.execute(
25+ "UPDATE verrou SET verrouille = 1, identifiant_session = ?, horodatage = ? WHERE id = 1",
26+ (identifiant_session, datetime.now(UTC).isoformat()),
27+ )
28+ manifest.conn.commit()
29+
30+
31+def verifier(manifest: ManifestHandle) -> bool:
32+ """Retourne `True` si le dossier est actuellement verrouillé."""
33+ ligne = manifest.conn.execute("SELECT verrouille FROM verrou WHERE id = 1").fetchone()
34+ return bool(ligne and ligne[0])
35+
36+
37+def lever(manifest: ManifestHandle) -> None:
38+ """Lève le verrou (FR-016), une fois toutes les écritures d'une réconciliation terminées."""
39+ manifest.conn.execute(
40+ "UPDATE verrou SET verrouille = 0, identifiant_session = NULL, "
41+ "horodatage = NULL WHERE id = 1"
42+ )
43+ manifest.conn.commit()
new file mode 100644
@@ -0,0 +1,43 @@
1+"""Verrouillage d'un dossier pendant un checkout en cours (FR-003/004/016).
2+
3+Protection applicative de bonne foi entre instances de Régine sur un partage réseau
4+ordinaire — pas une garantie distribuée absolue (cf. research.md § 3).
5+"""
6+
7+from __future__ import annotations
8+
9+from datetime import UTC, datetime
10+
11+from regine_core.archive.manifest import ManifestHandle
12+
13+
14+class DossierDejaVerrouilleError(Exception):
15+ """Le dossier est déjà verrouillé par un checkout en cours (FR-004)."""
16+
17+
18+def poser(manifest: ManifestHandle, identifiant_session: str = "default") -> None:
19+ """Verrouille le dossier ; lève `DossierDejaVerrouilleError` s'il l'est déjà."""
20+ if verifier(manifest):
21+ raise DossierDejaVerrouilleError(
22+ f"{manifest.dossier} est déjà verrouillé (checkout en cours)"
23+ )
24+ manifest.conn.execute(
25+ "UPDATE verrou SET verrouille = 1, identifiant_session = ?, horodatage = ? WHERE id = 1",
26+ (identifiant_session, datetime.now(UTC).isoformat()),
27+ )
28+ manifest.conn.commit()
29+
30+
31+def verifier(manifest: ManifestHandle) -> bool:
32+ """Retourne `True` si le dossier est actuellement verrouillé."""
33+ ligne = manifest.conn.execute("SELECT verrouille FROM verrou WHERE id = 1").fetchone()
34+ return bool(ligne and ligne[0])
35+
36+
37+def lever(manifest: ManifestHandle) -> None:
38+ """Lève le verrou (FR-016), une fois toutes les écritures d'une réconciliation terminées."""
39+ manifest.conn.execute(
40+ "UPDATE verrou SET verrouille = 0, identifiant_session = NULL, "
41+ "horodatage = NULL WHERE id = 1"
42+ )
43+ manifest.conn.commit()
added packages/regine-core/src/regine_core/integrity/__init__.py +15 -0
new file mode 100644
@@ -0,0 +1,15 @@
1+"""Hash à deux niveaux (fichier entier / image-only) et détection d'anomalie.
2+
3+Cf. specs/005-checkout-reconciliation. Implémente directement le Principe I de la
4+constitution du projet.
5+"""
6+
7+from regine_core.integrity.anomalie import est_maitre_modifie
8+from regine_core.integrity.hash import Empreinte, hash_fichier_entier, hash_image_only
9+
10+__all__ = [
11+ "Empreinte",
12+ "est_maitre_modifie",
13+ "hash_fichier_entier",
14+ "hash_image_only",
15+]
new file mode 100644
@@ -0,0 +1,15 @@
1+"""Hash à deux niveaux (fichier entier / image-only) et détection d'anomalie.
2+
3+Cf. specs/005-checkout-reconciliation. Implémente directement le Principe I de la
4+constitution du projet.
5+"""
6+
7+from regine_core.integrity.anomalie import est_maitre_modifie
8+from regine_core.integrity.hash import Empreinte, hash_fichier_entier, hash_image_only
9+
10+__all__ = [
11+ "Empreinte",
12+ "est_maitre_modifie",
13+ "hash_fichier_entier",
14+ "hash_image_only",
15+]
added packages/regine-core/src/regine_core/integrity/anomalie.py +30 -0
new file mode 100644
@@ -0,0 +1,30 @@
1+"""Décision anomalie / pas-anomalie sur un fichier maître (Principe I de la constitution)."""
2+
3+from __future__ import annotations
4+
5+from regine_core.integrity.hash import EXTENSIONS_HASH_DOUBLE, Empreinte
6+
7+
8+def est_maitre_modifie(extension: str, ref: Empreinte, actuel: Empreinte) -> bool:
9+ """Détermine si un fichier maître a réellement été modifié entre deux empreintes.
10+
11+ Pour les RAW propriétaires (extension hors `EXTENSIONS_HASH_DOUBLE`), la
12+ comparaison porte sur le hash fichier entier seul : ces formats ne sont jamais
13+ réécrits en place par les outils d'édition courants, tout changement est une
14+ anomalie réelle.
15+
16+ Pour DNG/TIFF/JPEG, la comparaison porte sur le hash image-only quand il est
17+ disponible des deux côtés : une édition de métadonnées (réglages non destructifs)
18+ change le hash fichier entier sans changer les pixels, et ne DOIT jamais être
19+ signalée comme anomalie. Si le hash image-only est indisponible d'un côté ou de
20+ l'autre, on retombe sur le hash fichier entier plutôt que de ne rien vérifier.
21+ """
22+ ext = extension.lstrip(".").lower()
23+ hash_double_disponible = (
24+ ext in EXTENSIONS_HASH_DOUBLE
25+ and ref.hash_image_only is not None
26+ and actuel.hash_image_only is not None
27+ )
28+ if hash_double_disponible:
29+ return ref.hash_image_only != actuel.hash_image_only
30+ return ref.hash_fichier_entier != actuel.hash_fichier_entier
new file mode 100644
@@ -0,0 +1,30 @@
1+"""Décision anomalie / pas-anomalie sur un fichier maître (Principe I de la constitution)."""
2+
3+from __future__ import annotations
4+
5+from regine_core.integrity.hash import EXTENSIONS_HASH_DOUBLE, Empreinte
6+
7+
8+def est_maitre_modifie(extension: str, ref: Empreinte, actuel: Empreinte) -> bool:
9+ """Détermine si un fichier maître a réellement été modifié entre deux empreintes.
10+
11+ Pour les RAW propriétaires (extension hors `EXTENSIONS_HASH_DOUBLE`), la
12+ comparaison porte sur le hash fichier entier seul : ces formats ne sont jamais
13+ réécrits en place par les outils d'édition courants, tout changement est une
14+ anomalie réelle.
15+
16+ Pour DNG/TIFF/JPEG, la comparaison porte sur le hash image-only quand il est
17+ disponible des deux côtés : une édition de métadonnées (réglages non destructifs)
18+ change le hash fichier entier sans changer les pixels, et ne DOIT jamais être
19+ signalée comme anomalie. Si le hash image-only est indisponible d'un côté ou de
20+ l'autre, on retombe sur le hash fichier entier plutôt que de ne rien vérifier.
21+ """
22+ ext = extension.lstrip(".").lower()
23+ hash_double_disponible = (
24+ ext in EXTENSIONS_HASH_DOUBLE
25+ and ref.hash_image_only is not None
26+ and actuel.hash_image_only is not None
27+ )
28+ if hash_double_disponible:
29+ return ref.hash_image_only != actuel.hash_image_only
30+ return ref.hash_fichier_entier != actuel.hash_fichier_entier
added packages/regine-core/src/regine_core/integrity/hash.py +54 -0
new file mode 100644
@@ -0,0 +1,54 @@
1+"""Calcul des empreintes de contenu à deux niveaux (Principe I de la constitution)."""
2+
3+from __future__ import annotations
4+
5+import hashlib
6+from dataclasses import dataclass
7+from pathlib import Path
8+
9+from regine_core.metadata.exif import read_image_data_hash
10+
11+#: Extensions pour lesquelles un hash image-only distinct du hash fichier entier a
12+#: un sens (les outils d'édition courants réécrivent leurs métadonnées directement
13+#: dans le fichier, sans sidecar, cf. Principe I).
14+EXTENSIONS_HASH_DOUBLE = {"dng", "tiff", "tif", "jpg", "jpeg"}
15+
16+_TAILLE_BLOC = 1024 * 1024
17+
18+
19+@dataclass(frozen=True)
20+class Empreinte:
21+ """Les deux niveaux d'empreinte de contenu d'un fichier, pour comparaison."""
22+
23+ hash_fichier_entier: str
24+ hash_image_only: str | None
25+
26+
27+def hash_fichier_entier(chemin: Path) -> str:
28+ """SHA-256 du fichier entier, calculé par lecture en flux (pas de chargement complet)."""
29+ hachage = hashlib.sha256()
30+ with chemin.open("rb") as f:
31+ for bloc in iter(lambda: f.read(_TAILLE_BLOC), b""):
32+ hachage.update(bloc)
33+ return hachage.hexdigest()
34+
35+
36+def hash_image_only(chemin: Path) -> str | None:
37+ """Hash portant uniquement sur les données image, pour DNG/TIFF/JPEG uniquement.
38+
39+ `None` pour tout autre format (RAW propriétaires compris) : le hash fichier
40+ entier suffit alors, ces formats n'étant jamais réécrits en place par les
41+ outils d'édition courants (cf. Principe I).
42+ """
43+ extension = chemin.suffix.lstrip(".").lower()
44+ if extension not in EXTENSIONS_HASH_DOUBLE:
45+ return None
46+ return read_image_data_hash(chemin)
47+
48+
49+def empreinte(chemin: Path) -> Empreinte:
50+ """Calcule l'empreinte à deux niveaux d'un fichier."""
51+ return Empreinte(
52+ hash_fichier_entier=hash_fichier_entier(chemin),
53+ hash_image_only=hash_image_only(chemin),
54+ )
new file mode 100644
@@ -0,0 +1,54 @@
1+"""Calcul des empreintes de contenu à deux niveaux (Principe I de la constitution)."""
2+
3+from __future__ import annotations
4+
5+import hashlib
6+from dataclasses import dataclass
7+from pathlib import Path
8+
9+from regine_core.metadata.exif import read_image_data_hash
10+
11+#: Extensions pour lesquelles un hash image-only distinct du hash fichier entier a
12+#: un sens (les outils d'édition courants réécrivent leurs métadonnées directement
13+#: dans le fichier, sans sidecar, cf. Principe I).
14+EXTENSIONS_HASH_DOUBLE = {"dng", "tiff", "tif", "jpg", "jpeg"}
15+
16+_TAILLE_BLOC = 1024 * 1024
17+
18+
19+@dataclass(frozen=True)
20+class Empreinte:
21+ """Les deux niveaux d'empreinte de contenu d'un fichier, pour comparaison."""
22+
23+ hash_fichier_entier: str
24+ hash_image_only: str | None
25+
26+
27+def hash_fichier_entier(chemin: Path) -> str:
28+ """SHA-256 du fichier entier, calculé par lecture en flux (pas de chargement complet)."""
29+ hachage = hashlib.sha256()
30+ with chemin.open("rb") as f:
31+ for bloc in iter(lambda: f.read(_TAILLE_BLOC), b""):
32+ hachage.update(bloc)
33+ return hachage.hexdigest()
34+
35+
36+def hash_image_only(chemin: Path) -> str | None:
37+ """Hash portant uniquement sur les données image, pour DNG/TIFF/JPEG uniquement.
38+
39+ `None` pour tout autre format (RAW propriétaires compris) : le hash fichier
40+ entier suffit alors, ces formats n'étant jamais réécrits en place par les
41+ outils d'édition courants (cf. Principe I).
42+ """
43+ extension = chemin.suffix.lstrip(".").lower()
44+ if extension not in EXTENSIONS_HASH_DOUBLE:
45+ return None
46+ return read_image_data_hash(chemin)
47+
48+
49+def empreinte(chemin: Path) -> Empreinte:
50+ """Calcule l'empreinte à deux niveaux d'un fichier."""
51+ return Empreinte(
52+ hash_fichier_entier=hash_fichier_entier(chemin),
53+ hash_image_only=hash_image_only(chemin),
54+ )
modified packages/regine-core/src/regine_core/metadata/exif.py +19 -0
@@ -125,3 +125,22 @@ def read_camera_tags(chemin: Path) -> CameraTags:
125125 modele=_nettoyer(data.get("Model")),
126126 numero_serie=_nettoyer(data.get("BodySerialNumber")),
127127 )
128+
129+
130+def read_image_data_hash(chemin: Path) -> str | None:
131+ """Calcule le hash portant uniquement sur les données image (pixels) d'un fichier.
132+
133+ Utilise `exiftool -api ImageHashType=SHA256 -ImageDataHash` (cf.
134+ specs/005-checkout-reconciliation research.md § 5) : stable à travers les
135+ éditions de métadonnées (réglages non destructifs), contrairement au hash du
136+ fichier entier. Pertinent pour DNG/TIFF/JPEG (cf. Principe I de la constitution) ;
137+ `None` si l'information n'est pas disponible.
138+ """
139+ brut = _get_session().execute(
140+ "-api", "ImageHashType=SHA256", "-json", "-ImageDataHash", str(chemin)
141+ )
142+ if not brut.strip():
143+ return None
144+ analyse = json.loads(brut)
145+ data = analyse[0] if analyse else {}
146+ return _nettoyer(data.get("ImageDataHash"))
@@ -125,3 +125,22 @@ def read_camera_tags(chemin: Path) -> CameraTags:
125 modele=_nettoyer(data.get("Model")),125 modele=_nettoyer(data.get("Model")),
126 numero_serie=_nettoyer(data.get("BodySerialNumber")),126 numero_serie=_nettoyer(data.get("BodySerialNumber")),
127 )127 )
128+
129+
130+def read_image_data_hash(chemin: Path) -> str | None:
131+ """Calcule le hash portant uniquement sur les données image (pixels) d'un fichier.
132+
133+ Utilise `exiftool -api ImageHashType=SHA256 -ImageDataHash` (cf.
134+ specs/005-checkout-reconciliation research.md § 5) : stable à travers les
135+ éditions de métadonnées (réglages non destructifs), contrairement au hash du
136+ fichier entier. Pertinent pour DNG/TIFF/JPEG (cf. Principe I de la constitution) ;
137+ `None` si l'information n'est pas disponible.
138+ """
139+ brut = _get_session().execute(
140+ "-api", "ImageHashType=SHA256", "-json", "-ImageDataHash", str(chemin)
141+ )
142+ if not brut.strip():
143+ return None
144+ analyse = json.loads(brut)
145+ data = analyse[0] if analyse else {}
146+ return _nettoyer(data.get("ImageDataHash"))
added packages/regine-core/tests/integration/test_checkout_partiel.py +50 -0
new file mode 100644
@@ -0,0 +1,50 @@
1+"""Test d'integration du checkout partiel par format (T034, T035, US6)."""
2+
3+from __future__ import annotations
4+
5+from pathlib import Path
6+
7+from regine_core.archive.checkout import checkout
8+from regine_core.archive.reconciliation import comparer
9+
10+
11+def _creer_dossier_archive(racine: Path) -> None:
12+ (racine / "raw").mkdir(parents=True)
13+ (racine / "jpeg").mkdir(parents=True)
14+ (racine / "raw" / "photo.raf").write_bytes(b"contenu-raw")
15+ (racine / "jpeg" / "photo.jpg").write_bytes(b"contenu-jpeg")
16+ (racine / "photo.jpg").write_bytes(b"contenu-jpeg-racine")
17+
18+
19+def test_partial_checkout_copies_only_requested_formats_and_root(tmp_path: Path) -> None:
20+ dossier_archive = tmp_path / "archive"
21+ dest_locale = tmp_path / "local"
22+ _creer_dossier_archive(dossier_archive)
23+
24+ snapshot = checkout(dossier_archive, dest_locale, formats=["jpeg"])
25+
26+ assert (dest_locale / "jpeg" / "photo.jpg").exists()
27+ assert (dest_locale / "photo.jpg").exists() # racine toujours incluse
28+ assert not (dest_locale / "raw").exists()
29+ assert set(snapshot.fichiers) == {"jpeg/photo.jpg", "photo.jpg"}
30+ snapshot.manifest.conn.close()
31+
32+
33+def test_reconciliation_after_partial_checkout_never_flags_excluded_files(tmp_path: Path) -> None:
34+ """FR-020 : les RAW exclus du checkout partiel ne sont jamais signalés comme
35+ supprimés à la réconciliation."""
36+ dossier_archive = tmp_path / "archive"
37+ dest_locale = tmp_path / "local"
38+ _creer_dossier_archive(dossier_archive)
39+
40+ snapshot = checkout(dossier_archive, dest_locale, formats=["jpeg"])
41+
42+ # Édition normale dans la partie checkoutée (nouveau sidecar), aucun RAW en local.
43+ (dest_locale / "jpeg" / "photo.jpg.xmp").write_bytes(b"reglages")
44+
45+ rapport = comparer(snapshot, dest_locale)
46+
47+ categories = {str(c.chemin): c.categorie for c in rapport.changements}
48+ assert "raw/photo.raf" not in categories # jamais signalé, ni suppression ni anomalie
49+ assert categories.get("jpeg/photo.jpg.xmp") == "nouveau"
50+ snapshot.manifest.conn.close()
new file mode 100644
@@ -0,0 +1,50 @@
1+"""Test d'integration du checkout partiel par format (T034, T035, US6)."""
2+
3+from __future__ import annotations
4+
5+from pathlib import Path
6+
7+from regine_core.archive.checkout import checkout
8+from regine_core.archive.reconciliation import comparer
9+
10+
11+def _creer_dossier_archive(racine: Path) -> None:
12+ (racine / "raw").mkdir(parents=True)
13+ (racine / "jpeg").mkdir(parents=True)
14+ (racine / "raw" / "photo.raf").write_bytes(b"contenu-raw")
15+ (racine / "jpeg" / "photo.jpg").write_bytes(b"contenu-jpeg")
16+ (racine / "photo.jpg").write_bytes(b"contenu-jpeg-racine")
17+
18+
19+def test_partial_checkout_copies_only_requested_formats_and_root(tmp_path: Path) -> None:
20+ dossier_archive = tmp_path / "archive"
21+ dest_locale = tmp_path / "local"
22+ _creer_dossier_archive(dossier_archive)
23+
24+ snapshot = checkout(dossier_archive, dest_locale, formats=["jpeg"])
25+
26+ assert (dest_locale / "jpeg" / "photo.jpg").exists()
27+ assert (dest_locale / "photo.jpg").exists() # racine toujours incluse
28+ assert not (dest_locale / "raw").exists()
29+ assert set(snapshot.fichiers) == {"jpeg/photo.jpg", "photo.jpg"}
30+ snapshot.manifest.conn.close()
31+
32+
33+def test_reconciliation_after_partial_checkout_never_flags_excluded_files(tmp_path: Path) -> None:
34+ """FR-020 : les RAW exclus du checkout partiel ne sont jamais signalés comme
35+ supprimés à la réconciliation."""
36+ dossier_archive = tmp_path / "archive"
37+ dest_locale = tmp_path / "local"
38+ _creer_dossier_archive(dossier_archive)
39+
40+ snapshot = checkout(dossier_archive, dest_locale, formats=["jpeg"])
41+
42+ # Édition normale dans la partie checkoutée (nouveau sidecar), aucun RAW en local.
43+ (dest_locale / "jpeg" / "photo.jpg.xmp").write_bytes(b"reglages")
44+
45+ rapport = comparer(snapshot, dest_locale)
46+
47+ categories = {str(c.chemin): c.categorie for c in rapport.changements}
48+ assert "raw/photo.raf" not in categories # jamais signalé, ni suppression ni anomalie
49+ assert categories.get("jpeg/photo.jpg.xmp") == "nouveau"
50+ snapshot.manifest.conn.close()
added packages/regine-core/tests/integration/test_cycle_checkout_reconciliation.py +104 -0
new file mode 100644
@@ -0,0 +1,104 @@
1+"""Test d'integration du cycle complet checkout -> edition -> reconciliation (T015, T020, T026).
2+
3+Complete progressivement au fil des User Stories 1 a 3.
4+"""
5+
6+from __future__ import annotations
7+
8+from pathlib import Path
9+
10+from regine_core.archive import verrou
11+from regine_core.archive.checkout import checkout
12+from regine_core.archive.reconciliation import (
13+ DecisionsUtilisateur,
14+ archiver,
15+ comparer,
16+)
17+
18+
19+def _creer_dossier_archive(racine: Path) -> None:
20+ (racine / "raw").mkdir(parents=True)
21+ (racine / "raw" / "photo.raf").write_bytes(b"contenu-raw-original")
22+ (racine / "photo.raf.xmp").write_bytes(b"reglages-v1")
23+
24+
25+def test_checkout_creates_reference_manifest_and_locks_folder(tmp_path: Path) -> None:
26+ """T015 (US1, partiel) : le checkout enregistre un manifeste complet et verrouille."""
27+ dossier_archive = tmp_path / "archive"
28+ dest_locale = tmp_path / "local"
29+ _creer_dossier_archive(dossier_archive)
30+
31+ snapshot = checkout(dossier_archive, dest_locale)
32+
33+ assert len(snapshot.fichiers) == 2
34+ assert verrou.verifier(snapshot.manifest) is True
35+ snapshot.manifest.conn.close()
36+
37+
38+def test_full_cycle_sidecar_edit_reconcile_and_unlock(tmp_path: Path) -> None:
39+ """T020 (US2) : édition sidecar -> réconciliation -> résumé -> confirmation ->
40+ réarchivage -> verrou levé."""
41+ dossier_archive = tmp_path / "archive"
42+ dest_locale = tmp_path / "local"
43+ _creer_dossier_archive(dossier_archive)
44+
45+ snapshot = checkout(dossier_archive, dest_locale)
46+
47+ # Édition locale : sidecar modifié, fichier maître inchangé.
48+ (dest_locale / "photo.raf.xmp").write_bytes(b"reglages-v2")
49+
50+ rapport = comparer(snapshot, dest_locale)
51+ assert len(rapport.changements) == 1
52+ assert rapport.changements[0].categorie == "normal"
53+ assert rapport.anomalies == []
54+
55+ # Rien n'est écrit tant que la confirmation n'est pas donnée.
56+ contenu_archive_avant = (dossier_archive / "photo.raf.xmp").read_bytes()
57+ assert contenu_archive_avant == b"reglages-v1"
58+
59+ decisions = DecisionsUtilisateur(confirmer_changements_normaux=True)
60+ archiver(snapshot, rapport, decisions)
61+
62+ assert (dossier_archive / "photo.raf.xmp").read_bytes() == b"reglages-v2"
63+ assert verrou.verifier(snapshot.manifest) is False
64+ snapshot.manifest.conn.close()
65+
66+
67+def test_anomaly_decision_does_not_block_already_confirmed_normal_changes(tmp_path: Path) -> None:
68+ """T026 (US3) : une anomalie non résolue reste signalée mais n'empêche pas
69+ l'archivage des changements normaux déjà confirmés dans la même session (FR-014).
70+ Le verrou ne doit se lever qu'une fois l'anomalie elle-même traitée."""
71+ dossier_archive = tmp_path / "archive"
72+ dest_locale = tmp_path / "local"
73+ _creer_dossier_archive(dossier_archive)
74+
75+ snapshot = checkout(dossier_archive, dest_locale)
76+
77+ # Un sidecar est édité normalement, et le fichier maître est modifié (anomalie).
78+ (dest_locale / "photo.raf.xmp").write_bytes(b"reglages-v2")
79+ (dest_locale / "raw" / "photo.raf").write_bytes(b"contenu-raw-modifie-par-erreur")
80+
81+ rapport = comparer(snapshot, dest_locale)
82+ assert len(rapport.anomalies) == 1
83+ assert len(rapport.changements) == 2
84+
85+ # L'utilisateur confirme les changements normaux mais ne tranche pas encore
86+ # l'anomalie : le sidecar doit être archivé, le fichier maître suspect non.
87+ decisions = DecisionsUtilisateur(confirmer_changements_normaux=True)
88+ archiver(snapshot, rapport, decisions)
89+
90+ assert (dossier_archive / "photo.raf.xmp").read_bytes() == b"reglages-v2"
91+ assert (dossier_archive / "raw" / "photo.raf").read_bytes() == b"contenu-raw-original"
92+ assert verrou.verifier(snapshot.manifest) is True # anomalie non résolue : verrou conservé
93+
94+ # L'utilisateur choisit ensuite de restaurer le fichier maître depuis l'archive.
95+ decisions_2 = DecisionsUtilisateur(
96+ confirmer_changements_normaux=True,
97+ resolutions_anomalies={"raw/photo.raf": "restaurer"},
98+ )
99+ rapport_2 = comparer(snapshot, dest_locale)
100+ archiver(snapshot, rapport_2, decisions_2)
101+
102+ assert (dest_locale / "raw" / "photo.raf").read_bytes() == b"contenu-raw-original"
103+ assert verrou.verifier(snapshot.manifest) is False
104+ snapshot.manifest.conn.close()
new file mode 100644
@@ -0,0 +1,104 @@
1+"""Test d'integration du cycle complet checkout -> edition -> reconciliation (T015, T020, T026).
2+
3+Complete progressivement au fil des User Stories 1 a 3.
4+"""
5+
6+from __future__ import annotations
7+
8+from pathlib import Path
9+
10+from regine_core.archive import verrou
11+from regine_core.archive.checkout import checkout
12+from regine_core.archive.reconciliation import (
13+ DecisionsUtilisateur,
14+ archiver,
15+ comparer,
16+)
17+
18+
19+def _creer_dossier_archive(racine: Path) -> None:
20+ (racine / "raw").mkdir(parents=True)
21+ (racine / "raw" / "photo.raf").write_bytes(b"contenu-raw-original")
22+ (racine / "photo.raf.xmp").write_bytes(b"reglages-v1")
23+
24+
25+def test_checkout_creates_reference_manifest_and_locks_folder(tmp_path: Path) -> None:
26+ """T015 (US1, partiel) : le checkout enregistre un manifeste complet et verrouille."""
27+ dossier_archive = tmp_path / "archive"
28+ dest_locale = tmp_path / "local"
29+ _creer_dossier_archive(dossier_archive)
30+
31+ snapshot = checkout(dossier_archive, dest_locale)
32+
33+ assert len(snapshot.fichiers) == 2
34+ assert verrou.verifier(snapshot.manifest) is True
35+ snapshot.manifest.conn.close()
36+
37+
38+def test_full_cycle_sidecar_edit_reconcile_and_unlock(tmp_path: Path) -> None:
39+ """T020 (US2) : édition sidecar -> réconciliation -> résumé -> confirmation ->
40+ réarchivage -> verrou levé."""
41+ dossier_archive = tmp_path / "archive"
42+ dest_locale = tmp_path / "local"
43+ _creer_dossier_archive(dossier_archive)
44+
45+ snapshot = checkout(dossier_archive, dest_locale)
46+
47+ # Édition locale : sidecar modifié, fichier maître inchangé.
48+ (dest_locale / "photo.raf.xmp").write_bytes(b"reglages-v2")
49+
50+ rapport = comparer(snapshot, dest_locale)
51+ assert len(rapport.changements) == 1
52+ assert rapport.changements[0].categorie == "normal"
53+ assert rapport.anomalies == []
54+
55+ # Rien n'est écrit tant que la confirmation n'est pas donnée.
56+ contenu_archive_avant = (dossier_archive / "photo.raf.xmp").read_bytes()
57+ assert contenu_archive_avant == b"reglages-v1"
58+
59+ decisions = DecisionsUtilisateur(confirmer_changements_normaux=True)
60+ archiver(snapshot, rapport, decisions)
61+
62+ assert (dossier_archive / "photo.raf.xmp").read_bytes() == b"reglages-v2"
63+ assert verrou.verifier(snapshot.manifest) is False
64+ snapshot.manifest.conn.close()
65+
66+
67+def test_anomaly_decision_does_not_block_already_confirmed_normal_changes(tmp_path: Path) -> None:
68+ """T026 (US3) : une anomalie non résolue reste signalée mais n'empêche pas
69+ l'archivage des changements normaux déjà confirmés dans la même session (FR-014).
70+ Le verrou ne doit se lever qu'une fois l'anomalie elle-même traitée."""
71+ dossier_archive = tmp_path / "archive"
72+ dest_locale = tmp_path / "local"
73+ _creer_dossier_archive(dossier_archive)
74+
75+ snapshot = checkout(dossier_archive, dest_locale)
76+
77+ # Un sidecar est édité normalement, et le fichier maître est modifié (anomalie).
78+ (dest_locale / "photo.raf.xmp").write_bytes(b"reglages-v2")
79+ (dest_locale / "raw" / "photo.raf").write_bytes(b"contenu-raw-modifie-par-erreur")
80+
81+ rapport = comparer(snapshot, dest_locale)
82+ assert len(rapport.anomalies) == 1
83+ assert len(rapport.changements) == 2
84+
85+ # L'utilisateur confirme les changements normaux mais ne tranche pas encore
86+ # l'anomalie : le sidecar doit être archivé, le fichier maître suspect non.
87+ decisions = DecisionsUtilisateur(confirmer_changements_normaux=True)
88+ archiver(snapshot, rapport, decisions)
89+
90+ assert (dossier_archive / "photo.raf.xmp").read_bytes() == b"reglages-v2"
91+ assert (dossier_archive / "raw" / "photo.raf").read_bytes() == b"contenu-raw-original"
92+ assert verrou.verifier(snapshot.manifest) is True # anomalie non résolue : verrou conservé
93+
94+ # L'utilisateur choisit ensuite de restaurer le fichier maître depuis l'archive.
95+ decisions_2 = DecisionsUtilisateur(
96+ confirmer_changements_normaux=True,
97+ resolutions_anomalies={"raw/photo.raf": "restaurer"},
98+ )
99+ rapport_2 = comparer(snapshot, dest_locale)
100+ archiver(snapshot, rapport_2, decisions_2)
101+
102+ assert (dest_locale / "raw" / "photo.raf").read_bytes() == b"contenu-raw-original"
103+ assert verrou.verifier(snapshot.manifest) is False
104+ snapshot.manifest.conn.close()
added packages/regine-core/tests/integration/test_double_checkout.py +43 -0
new file mode 100644
@@ -0,0 +1,43 @@
1+"""Test d'integration bout-en-bout : refus d'un double-checkout concurrent (T032, US5)."""
2+
3+from __future__ import annotations
4+
5+from pathlib import Path
6+
7+from regine_cli.archive_cmd import main
8+
9+
10+def _creer_dossier_archive(racine: Path) -> None:
11+ racine.mkdir(parents=True)
12+ (racine / "photo.raf").write_bytes(b"contenu-raw")
13+
14+
15+def test_second_checkout_refused_while_first_is_in_progress(tmp_path: Path, capsys) -> None:
16+ dossier_archive = tmp_path / "archive"
17+ _creer_dossier_archive(dossier_archive)
18+
19+ code_1 = main(["checkout", str(dossier_archive), "--local-dest", str(tmp_path / "local1")])
20+ assert code_1 == 0
21+
22+ code_2 = main(["checkout", str(dossier_archive), "--local-dest", str(tmp_path / "local2")])
23+ assert code_2 != 0
24+ erreur = capsys.readouterr().err
25+ assert "déjà" in erreur.lower() or "verrouill" in erreur.lower()
26+
27+
28+def test_checkout_accepted_again_after_reconciliation(tmp_path: Path, monkeypatch) -> None:
29+ dossier_archive = tmp_path / "archive"
30+ _creer_dossier_archive(dossier_archive)
31+
32+ code_1 = main(["checkout", str(dossier_archive), "--local-dest", str(tmp_path / "local1")])
33+ assert code_1 == 0
34+
35+ # Réconciliation sans aucun changement local : rien à confirmer, le verrou est
36+ # néanmoins levé puisqu'il n'y a rien à traiter.
37+ code_reconcile = main(
38+ ["reconcile", str(dossier_archive), "--local-dest", str(tmp_path / "local1")]
39+ )
40+ assert code_reconcile == 0
41+
42+ code_3 = main(["checkout", str(dossier_archive), "--local-dest", str(tmp_path / "local3")])
43+ assert code_3 == 0
new file mode 100644
@@ -0,0 +1,43 @@
1+"""Test d'integration bout-en-bout : refus d'un double-checkout concurrent (T032, US5)."""
2+
3+from __future__ import annotations
4+
5+from pathlib import Path
6+
7+from regine_cli.archive_cmd import main
8+
9+
10+def _creer_dossier_archive(racine: Path) -> None:
11+ racine.mkdir(parents=True)
12+ (racine / "photo.raf").write_bytes(b"contenu-raw")
13+
14+
15+def test_second_checkout_refused_while_first_is_in_progress(tmp_path: Path, capsys) -> None:
16+ dossier_archive = tmp_path / "archive"
17+ _creer_dossier_archive(dossier_archive)
18+
19+ code_1 = main(["checkout", str(dossier_archive), "--local-dest", str(tmp_path / "local1")])
20+ assert code_1 == 0
21+
22+ code_2 = main(["checkout", str(dossier_archive), "--local-dest", str(tmp_path / "local2")])
23+ assert code_2 != 0
24+ erreur = capsys.readouterr().err
25+ assert "déjà" in erreur.lower() or "verrouill" in erreur.lower()
26+
27+
28+def test_checkout_accepted_again_after_reconciliation(tmp_path: Path, monkeypatch) -> None:
29+ dossier_archive = tmp_path / "archive"
30+ _creer_dossier_archive(dossier_archive)
31+
32+ code_1 = main(["checkout", str(dossier_archive), "--local-dest", str(tmp_path / "local1")])
33+ assert code_1 == 0
34+
35+ # Réconciliation sans aucun changement local : rien à confirmer, le verrou est
36+ # néanmoins levé puisqu'il n'y a rien à traiter.
37+ code_reconcile = main(
38+ ["reconcile", str(dossier_archive), "--local-dest", str(tmp_path / "local1")]
39+ )
40+ assert code_reconcile == 0
41+
42+ code_3 = main(["checkout", str(dossier_archive), "--local-dest", str(tmp_path / "local3")])
43+ assert code_3 == 0
added packages/regine-core/tests/unit/test_checkout.py +54 -0
new file mode 100644
@@ -0,0 +1,54 @@
1+"""Tests unitaires du checkout (T014)."""
2+
3+from __future__ import annotations
4+
5+from pathlib import Path
6+
7+from regine_core.archive.checkout import checkout
8+from regine_core.integrity.hash import hash_fichier_entier
9+
10+
11+def _creer_dossier_archive(racine: Path) -> None:
12+ (racine / "raw").mkdir(parents=True)
13+ (racine / "jpeg").mkdir(parents=True)
14+ (racine / "raw" / "2026-01-01_Titre_RD0001.RAF").write_bytes(b"contenu-raw")
15+ (racine / "jpeg" / "2026-01-01_Titre_RD0001.JPG").write_bytes(b"contenu-jpeg")
16+ (racine / "2026-01-01_Titre_RD0001.JPG").write_bytes(b"contenu-jpeg-promu-racine")
17+
18+
19+def test_checkout_copies_parent_and_all_subfolders_together(tmp_path: Path) -> None:
20+ dossier_archive = tmp_path / "archive" / "2026" / "2026-01-01_Titre"
21+ dest_locale = tmp_path / "local" / "2026-01-01_Titre"
22+ _creer_dossier_archive(dossier_archive)
23+
24+ snapshot = checkout(dossier_archive, dest_locale)
25+
26+ assert (dest_locale / "raw" / "2026-01-01_Titre_RD0001.RAF").exists()
27+ assert (dest_locale / "jpeg" / "2026-01-01_Titre_RD0001.JPG").exists()
28+ assert (dest_locale / "2026-01-01_Titre_RD0001.JPG").exists()
29+ assert len(snapshot.fichiers) == 3
30+ snapshot.manifest.conn.close()
31+
32+
33+def test_checkout_verifies_each_file_by_hash(tmp_path: Path) -> None:
34+ dossier_archive = tmp_path / "archive"
35+ dest_locale = tmp_path / "local"
36+ _creer_dossier_archive(dossier_archive)
37+
38+ snapshot = checkout(dossier_archive, dest_locale)
39+
40+ for chemin_relatif, entree in snapshot.fichiers.items():
41+ assert entree.hash_fichier_entier == hash_fichier_entier(dest_locale / chemin_relatif)
42+ snapshot.manifest.conn.close()
43+
44+
45+def test_checkout_records_reference_manifest(tmp_path: Path) -> None:
46+ dossier_archive = tmp_path / "archive"
47+ dest_locale = tmp_path / "local"
48+ _creer_dossier_archive(dossier_archive)
49+
50+ snapshot = checkout(dossier_archive, dest_locale)
51+
52+ lignes = snapshot.manifest.conn.execute("SELECT chemin_relatif FROM fichiers").fetchall()
53+ assert len(lignes) == 3
54+ snapshot.manifest.conn.close()
new file mode 100644
@@ -0,0 +1,54 @@
1+"""Tests unitaires du checkout (T014)."""
2+
3+from __future__ import annotations
4+
5+from pathlib import Path
6+
7+from regine_core.archive.checkout import checkout
8+from regine_core.integrity.hash import hash_fichier_entier
9+
10+
11+def _creer_dossier_archive(racine: Path) -> None:
12+ (racine / "raw").mkdir(parents=True)
13+ (racine / "jpeg").mkdir(parents=True)
14+ (racine / "raw" / "2026-01-01_Titre_RD0001.RAF").write_bytes(b"contenu-raw")
15+ (racine / "jpeg" / "2026-01-01_Titre_RD0001.JPG").write_bytes(b"contenu-jpeg")
16+ (racine / "2026-01-01_Titre_RD0001.JPG").write_bytes(b"contenu-jpeg-promu-racine")
17+
18+
19+def test_checkout_copies_parent_and_all_subfolders_together(tmp_path: Path) -> None:
20+ dossier_archive = tmp_path / "archive" / "2026" / "2026-01-01_Titre"
21+ dest_locale = tmp_path / "local" / "2026-01-01_Titre"
22+ _creer_dossier_archive(dossier_archive)
23+
24+ snapshot = checkout(dossier_archive, dest_locale)
25+
26+ assert (dest_locale / "raw" / "2026-01-01_Titre_RD0001.RAF").exists()
27+ assert (dest_locale / "jpeg" / "2026-01-01_Titre_RD0001.JPG").exists()
28+ assert (dest_locale / "2026-01-01_Titre_RD0001.JPG").exists()
29+ assert len(snapshot.fichiers) == 3
30+ snapshot.manifest.conn.close()
31+
32+
33+def test_checkout_verifies_each_file_by_hash(tmp_path: Path) -> None:
34+ dossier_archive = tmp_path / "archive"
35+ dest_locale = tmp_path / "local"
36+ _creer_dossier_archive(dossier_archive)
37+
38+ snapshot = checkout(dossier_archive, dest_locale)
39+
40+ for chemin_relatif, entree in snapshot.fichiers.items():
41+ assert entree.hash_fichier_entier == hash_fichier_entier(dest_locale / chemin_relatif)
42+ snapshot.manifest.conn.close()
43+
44+
45+def test_checkout_records_reference_manifest(tmp_path: Path) -> None:
46+ dossier_archive = tmp_path / "archive"
47+ dest_locale = tmp_path / "local"
48+ _creer_dossier_archive(dossier_archive)
49+
50+ snapshot = checkout(dossier_archive, dest_locale)
51+
52+ lignes = snapshot.manifest.conn.execute("SELECT chemin_relatif FROM fichiers").fetchall()
53+ assert len(lignes) == 3
54+ snapshot.manifest.conn.close()
added packages/regine-core/tests/unit/test_hash_deux_niveaux.py +105 -0
new file mode 100644
@@ -0,0 +1,105 @@
1+"""Tests du hash a deux niveaux et de la decision d'anomalie (T008)."""
2+
3+from __future__ import annotations
4+
5+import base64
6+import shutil
7+import subprocess
8+from pathlib import Path
9+
10+import pytest
11+from regine_core.integrity.anomalie import est_maitre_modifie
12+from regine_core.integrity.hash import Empreinte, empreinte, hash_fichier_entier, hash_image_only
13+from regine_core.metadata.exif import close_session
14+
15+_JPEG_1X1_BASE64 = (
16+ "/9j/4AAQSkZJRgABAQEAYABgAAD/2wBDAAMCAgICAgMCAgIDAwMDBAYEBAQEBAgGBgUGCQgKCgkICQkKDA8M"
17+ "CgsOCwkJDRENDg8QEBEQCgwSExIQEw8QEBD/2wBDAQMDAwQDBAgEBAgQCwkLEBAQEBAQEBAQEBAQEBAQEBAQ"
18+ "EBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBD/wAARCAABAAEDASIAAhEBAxEB/8QAFQABAQAA"
19+ "AAAAAAAAAAAAAAAAAAj/xAAUEAEAAAAAAAAAAAAAAAAAAAAA/8QAFQEBAQAAAAAAAAAAAAAAAAAAAAX/xAAU"
20+ "EQEAAAAAAAAAAAAAAAAAAAAA/9oADAMBAAIRAxEAPwCdABmX/9k="
21+)
22+
23+pytestmark = pytest.mark.skipif(shutil.which("exiftool") is None, reason="exiftool non installé")
24+
25+
26+@pytest.fixture(autouse=True)
27+def _close_shared_session():
28+ yield
29+ close_session()
30+
31+
32+def _jpeg(tmp_path: Path, nom: str = "photo.jpg") -> Path:
33+ chemin = tmp_path / nom
34+ chemin.write_bytes(base64.b64decode(_JPEG_1X1_BASE64))
35+ return chemin
36+
37+
38+def _tagger(chemin: Path, **tags: str) -> None:
39+ args = [f"-{cle}={valeur}" for cle, valeur in tags.items()]
40+ subprocess.run( # noqa: S603, S607
41+ ["exiftool", *args, "-overwrite_original", str(chemin)], check=True, capture_output=True
42+ )
43+
44+
45+def test_hash_fichier_entier_is_stable_for_unchanged_file(tmp_path: Path) -> None:
46+ chemin = _jpeg(tmp_path)
47+ assert hash_fichier_entier(chemin) == hash_fichier_entier(chemin)
48+
49+
50+def test_hash_image_only_none_for_raw_proprietary_extension(tmp_path: Path) -> None:
51+ chemin = tmp_path / "photo.raf"
52+ chemin.write_bytes(b"contenu-raw-quelconque")
53+ assert hash_image_only(chemin) is None
54+
55+
56+def test_hash_image_only_stable_across_metadata_edit_for_jpeg(tmp_path: Path) -> None:
57+ chemin = _jpeg(tmp_path)
58+ hash_avant = hash_image_only(chemin)
59+
60+ _tagger(chemin, Model="Fujifilm X100V")
61+
62+ hash_apres = hash_image_only(chemin)
63+ assert hash_avant == hash_apres
64+ assert hash_avant is not None
65+
66+
67+def test_est_maitre_modifie_raw_proprietaire_any_change_is_anomaly() -> None:
68+ ref = Empreinte(hash_fichier_entier="aaa", hash_image_only=None)
69+ actuel = Empreinte(hash_fichier_entier="bbb", hash_image_only=None)
70+
71+ assert est_maitre_modifie("raf", ref, actuel) is True
72+
73+
74+def test_est_maitre_modifie_raw_proprietaire_unchanged_is_not_anomaly() -> None:
75+ ref = Empreinte(hash_fichier_entier="aaa", hash_image_only=None)
76+ actuel = Empreinte(hash_fichier_entier="aaa", hash_image_only=None)
77+
78+ assert est_maitre_modifie("raf", ref, actuel) is False
79+
80+
81+def test_est_maitre_modifie_dng_metadata_only_edit_is_not_anomaly() -> None:
82+ """Hash fichier entier change (métadonnées), hash image-only inchangé : pas d'anomalie."""
83+ ref = Empreinte(hash_fichier_entier="aaa", hash_image_only="pixels-1")
84+ actuel = Empreinte(hash_fichier_entier="bbb", hash_image_only="pixels-1")
85+
86+ assert est_maitre_modifie("dng", ref, actuel) is False
87+
88+
89+def test_est_maitre_modifie_dng_pixel_change_is_anomaly() -> None:
90+ ref = Empreinte(hash_fichier_entier="aaa", hash_image_only="pixels-1")
91+ actuel = Empreinte(hash_fichier_entier="bbb", hash_image_only="pixels-2")
92+
93+ assert est_maitre_modifie("dng", ref, actuel) is True
94+
95+
96+def test_est_maitre_modifie_end_to_end_jpeg_master(tmp_path: Path) -> None:
97+ """Bout en bout : édition de métadonnées sur un vrai fichier ne déclenche pas d'anomalie."""
98+ chemin = _jpeg(tmp_path)
99+ ref = empreinte(chemin)
100+
101+ _tagger(chemin, Model="Fujifilm X100V")
102+
103+ actuel = empreinte(chemin)
104+ assert ref.hash_fichier_entier != actuel.hash_fichier_entier # le fichier a bien changé
105+ assert est_maitre_modifie("jpg", ref, actuel) is False # mais pas les pixels
new file mode 100644
@@ -0,0 +1,105 @@
1+"""Tests du hash a deux niveaux et de la decision d'anomalie (T008)."""
2+
3+from __future__ import annotations
4+
5+import base64
6+import shutil
7+import subprocess
8+from pathlib import Path
9+
10+import pytest
11+from regine_core.integrity.anomalie import est_maitre_modifie
12+from regine_core.integrity.hash import Empreinte, empreinte, hash_fichier_entier, hash_image_only
13+from regine_core.metadata.exif import close_session
14+
15+_JPEG_1X1_BASE64 = (
16+ "/9j/4AAQSkZJRgABAQEAYABgAAD/2wBDAAMCAgICAgMCAgIDAwMDBAYEBAQEBAgGBgUGCQgKCgkICQkKDA8M"
17+ "CgsOCwkJDRENDg8QEBEQCgwSExIQEw8QEBD/2wBDAQMDAwQDBAgEBAgQCwkLEBAQEBAQEBAQEBAQEBAQEBAQ"
18+ "EBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBD/wAARCAABAAEDASIAAhEBAxEB/8QAFQABAQAA"
19+ "AAAAAAAAAAAAAAAAAAj/xAAUEAEAAAAAAAAAAAAAAAAAAAAA/8QAFQEBAQAAAAAAAAAAAAAAAAAAAAX/xAAU"
20+ "EQEAAAAAAAAAAAAAAAAAAAAA/9oADAMBAAIRAxEAPwCdABmX/9k="
21+)
22+
23+pytestmark = pytest.mark.skipif(shutil.which("exiftool") is None, reason="exiftool non installé")
24+
25+
26+@pytest.fixture(autouse=True)
27+def _close_shared_session():
28+ yield
29+ close_session()
30+
31+
32+def _jpeg(tmp_path: Path, nom: str = "photo.jpg") -> Path:
33+ chemin = tmp_path / nom
34+ chemin.write_bytes(base64.b64decode(_JPEG_1X1_BASE64))
35+ return chemin
36+
37+
38+def _tagger(chemin: Path, **tags: str) -> None:
39+ args = [f"-{cle}={valeur}" for cle, valeur in tags.items()]
40+ subprocess.run( # noqa: S603, S607
41+ ["exiftool", *args, "-overwrite_original", str(chemin)], check=True, capture_output=True
42+ )
43+
44+
45+def test_hash_fichier_entier_is_stable_for_unchanged_file(tmp_path: Path) -> None:
46+ chemin = _jpeg(tmp_path)
47+ assert hash_fichier_entier(chemin) == hash_fichier_entier(chemin)
48+
49+
50+def test_hash_image_only_none_for_raw_proprietary_extension(tmp_path: Path) -> None:
51+ chemin = tmp_path / "photo.raf"
52+ chemin.write_bytes(b"contenu-raw-quelconque")
53+ assert hash_image_only(chemin) is None
54+
55+
56+def test_hash_image_only_stable_across_metadata_edit_for_jpeg(tmp_path: Path) -> None:
57+ chemin = _jpeg(tmp_path)
58+ hash_avant = hash_image_only(chemin)
59+
60+ _tagger(chemin, Model="Fujifilm X100V")
61+
62+ hash_apres = hash_image_only(chemin)
63+ assert hash_avant == hash_apres
64+ assert hash_avant is not None
65+
66+
67+def test_est_maitre_modifie_raw_proprietaire_any_change_is_anomaly() -> None:
68+ ref = Empreinte(hash_fichier_entier="aaa", hash_image_only=None)
69+ actuel = Empreinte(hash_fichier_entier="bbb", hash_image_only=None)
70+
71+ assert est_maitre_modifie("raf", ref, actuel) is True
72+
73+
74+def test_est_maitre_modifie_raw_proprietaire_unchanged_is_not_anomaly() -> None:
75+ ref = Empreinte(hash_fichier_entier="aaa", hash_image_only=None)
76+ actuel = Empreinte(hash_fichier_entier="aaa", hash_image_only=None)
77+
78+ assert est_maitre_modifie("raf", ref, actuel) is False
79+
80+
81+def test_est_maitre_modifie_dng_metadata_only_edit_is_not_anomaly() -> None:
82+ """Hash fichier entier change (métadonnées), hash image-only inchangé : pas d'anomalie."""
83+ ref = Empreinte(hash_fichier_entier="aaa", hash_image_only="pixels-1")
84+ actuel = Empreinte(hash_fichier_entier="bbb", hash_image_only="pixels-1")
85+
86+ assert est_maitre_modifie("dng", ref, actuel) is False
87+
88+
89+def test_est_maitre_modifie_dng_pixel_change_is_anomaly() -> None:
90+ ref = Empreinte(hash_fichier_entier="aaa", hash_image_only="pixels-1")
91+ actuel = Empreinte(hash_fichier_entier="bbb", hash_image_only="pixels-2")
92+
93+ assert est_maitre_modifie("dng", ref, actuel) is True
94+
95+
96+def test_est_maitre_modifie_end_to_end_jpeg_master(tmp_path: Path) -> None:
97+ """Bout en bout : édition de métadonnées sur un vrai fichier ne déclenche pas d'anomalie."""
98+ chemin = _jpeg(tmp_path)
99+ ref = empreinte(chemin)
100+
101+ _tagger(chemin, Model="Fujifilm X100V")
102+
103+ actuel = empreinte(chemin)
104+ assert ref.hash_fichier_entier != actuel.hash_fichier_entier # le fichier a bien changé
105+ assert est_maitre_modifie("jpg", ref, actuel) is False # mais pas les pixels
added packages/regine-core/tests/unit/test_manifest_versioning.py +77 -0
new file mode 100644
@@ -0,0 +1,77 @@
1+"""Tests du versionnement de schema du manifeste (T011)."""
2+
3+from __future__ import annotations
4+
5+from pathlib import Path
6+
7+import pytest
8+from regine_core.archive.manifest import (
9+ APPLICATION_ID,
10+ STRUCTUREL_COURANT,
11+ FormatManifesteInvalideError,
12+ VersionStructurelleNonSupporteeError,
13+ _encoder_version,
14+ ouvrir_ou_creer,
15+)
16+
17+
18+def test_bootstrap_sets_application_id_and_version(tmp_path: Path) -> None:
19+ handle = ouvrir_ou_creer(tmp_path)
20+ try:
21+ assert handle.conn.execute("PRAGMA application_id").fetchone()[0] == APPLICATION_ID
22+ finally:
23+ handle.conn.close()
24+
25+
26+def test_reopen_is_idempotent_and_preserves_data(tmp_path: Path) -> None:
27+ handle_1 = ouvrir_ou_creer(tmp_path)
28+ handle_1.conn.execute(
29+ "INSERT INTO fichiers (chemin_relatif, taille, hash_fichier_entier) VALUES (?, ?, ?)",
30+ ("raw/a.raf", 100, "abc"),
31+ )
32+ handle_1.conn.commit()
33+ handle_1.conn.close()
34+
35+ handle_2 = ouvrir_ou_creer(tmp_path)
36+ try:
37+ rows = handle_2.conn.execute("SELECT chemin_relatif FROM fichiers").fetchall()
38+ assert rows == [("raw/a.raf",)]
39+ finally:
40+ handle_2.conn.close()
41+
42+
43+def test_additive_schema_evolution_is_tolerated(tmp_path: Path) -> None:
44+ """Une version structurelle identique mais un numero additif superieur est tolere."""
45+ handle = ouvrir_ou_creer(tmp_path)
46+ handle.conn.execute(f"PRAGMA user_version = {_encoder_version(STRUCTUREL_COURANT, 7)}")
47+ handle.conn.commit()
48+ handle.conn.close()
49+
50+ # Reouverture : ne doit pas lever, l'additif superieur est tolere.
51+ handle_2 = ouvrir_ou_creer(tmp_path)
52+ handle_2.conn.close()
53+
54+
55+def test_structural_version_mismatch_is_rejected(tmp_path: Path) -> None:
56+ handle = ouvrir_ou_creer(tmp_path)
57+ handle.conn.execute(f"PRAGMA user_version = {_encoder_version(STRUCTUREL_COURANT + 1, 0)}")
58+ handle.conn.commit()
59+ handle.conn.close()
60+
61+ with pytest.raises(VersionStructurelleNonSupporteeError):
62+ ouvrir_ou_creer(tmp_path)
63+
64+
65+def test_foreign_sqlite_file_is_rejected(tmp_path: Path) -> None:
66+ import sqlite3
67+
68+ from regine_core.archive.manifest import NOM_FICHIER_MANIFESTE
69+
70+ chemin = tmp_path / NOM_FICHIER_MANIFESTE
71+ conn = sqlite3.connect(chemin)
72+ conn.execute("PRAGMA application_id = 999")
73+ conn.commit()
74+ conn.close()
75+
76+ with pytest.raises(FormatManifesteInvalideError):
77+ ouvrir_ou_creer(tmp_path)
new file mode 100644
@@ -0,0 +1,77 @@
1+"""Tests du versionnement de schema du manifeste (T011)."""
2+
3+from __future__ import annotations
4+
5+from pathlib import Path
6+
7+import pytest
8+from regine_core.archive.manifest import (
9+ APPLICATION_ID,
10+ STRUCTUREL_COURANT,
11+ FormatManifesteInvalideError,
12+ VersionStructurelleNonSupporteeError,
13+ _encoder_version,
14+ ouvrir_ou_creer,
15+)
16+
17+
18+def test_bootstrap_sets_application_id_and_version(tmp_path: Path) -> None:
19+ handle = ouvrir_ou_creer(tmp_path)
20+ try:
21+ assert handle.conn.execute("PRAGMA application_id").fetchone()[0] == APPLICATION_ID
22+ finally:
23+ handle.conn.close()
24+
25+
26+def test_reopen_is_idempotent_and_preserves_data(tmp_path: Path) -> None:
27+ handle_1 = ouvrir_ou_creer(tmp_path)
28+ handle_1.conn.execute(
29+ "INSERT INTO fichiers (chemin_relatif, taille, hash_fichier_entier) VALUES (?, ?, ?)",
30+ ("raw/a.raf", 100, "abc"),
31+ )
32+ handle_1.conn.commit()
33+ handle_1.conn.close()
34+
35+ handle_2 = ouvrir_ou_creer(tmp_path)
36+ try:
37+ rows = handle_2.conn.execute("SELECT chemin_relatif FROM fichiers").fetchall()
38+ assert rows == [("raw/a.raf",)]
39+ finally:
40+ handle_2.conn.close()
41+
42+
43+def test_additive_schema_evolution_is_tolerated(tmp_path: Path) -> None:
44+ """Une version structurelle identique mais un numero additif superieur est tolere."""
45+ handle = ouvrir_ou_creer(tmp_path)
46+ handle.conn.execute(f"PRAGMA user_version = {_encoder_version(STRUCTUREL_COURANT, 7)}")
47+ handle.conn.commit()
48+ handle.conn.close()
49+
50+ # Reouverture : ne doit pas lever, l'additif superieur est tolere.
51+ handle_2 = ouvrir_ou_creer(tmp_path)
52+ handle_2.conn.close()
53+
54+
55+def test_structural_version_mismatch_is_rejected(tmp_path: Path) -> None:
56+ handle = ouvrir_ou_creer(tmp_path)
57+ handle.conn.execute(f"PRAGMA user_version = {_encoder_version(STRUCTUREL_COURANT + 1, 0)}")
58+ handle.conn.commit()
59+ handle.conn.close()
60+
61+ with pytest.raises(VersionStructurelleNonSupporteeError):
62+ ouvrir_ou_creer(tmp_path)
63+
64+
65+def test_foreign_sqlite_file_is_rejected(tmp_path: Path) -> None:
66+ import sqlite3
67+
68+ from regine_core.archive.manifest import NOM_FICHIER_MANIFESTE
69+
70+ chemin = tmp_path / NOM_FICHIER_MANIFESTE
71+ conn = sqlite3.connect(chemin)
72+ conn.execute("PRAGMA application_id = 999")
73+ conn.commit()
74+ conn.close()
75+
76+ with pytest.raises(FormatManifesteInvalideError):
77+ ouvrir_ou_creer(tmp_path)
added packages/regine-core/tests/unit/test_reconciliation_classification.py +156 -0
new file mode 100644
@@ -0,0 +1,156 @@
1+"""Tests de la classification de reconciliation (T019, T024, T025, T029, T030)."""
2+
3+from __future__ import annotations
4+
5+import base64
6+import shutil
7+import subprocess
8+from pathlib import Path
9+
10+import pytest
11+from regine_core.archive.checkout import checkout
12+from regine_core.archive.reconciliation import comparer
13+from regine_core.metadata.exif import close_session
14+
15+_JPEG_1X1_BASE64 = (
16+ "/9j/4AAQSkZJRgABAQEAYABgAAD/2wBDAAMCAgICAgMCAgIDAwMDBAYEBAQEBAgGBgUGCQgKCgkICQkKDA8M"
17+ "CgsOCwkJDRENDg8QEBEQCgwSExIQEw8QEBD/2wBDAQMDAwQDBAgEBAgQCwkLEBAQEBAQEBAQEBAQEBAQEBAQ"
18+ "EBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBD/wAARCAABAAEDASIAAhEBAxEB/8QAFQABAQAA"
19+ "AAAAAAAAAAAAAAAAAAj/xAAUEAEAAAAAAAAAAAAAAAAAAAAA/8QAFQEBAQAAAAAAAAAAAAAAAAAAAAX/xAAU"
20+ "EQEAAAAAAAAAAAAAAAAAAAAA/9oADAMBAAIRAxEAPwCdABmX/9k="
21+)
22+
23+pytestmark = pytest.mark.skipif(shutil.which("exiftool") is None, reason="exiftool non installé")
24+
25+
26+@pytest.fixture(autouse=True)
27+def _close_shared_session():
28+ yield
29+ close_session()
30+
31+
32+def _categorie(rapport, chemin: str) -> str | None:
33+ for c in rapport.changements:
34+ if str(c.chemin) == chemin:
35+ return c.categorie
36+ return None
37+
38+
39+def test_sidecar_change_is_normal(tmp_path: Path) -> None:
40+ dossier_archive = tmp_path / "archive"
41+ dossier_archive.mkdir()
42+ (dossier_archive / "photo.raf").write_bytes(b"raw-inchange")
43+ (dossier_archive / "photo.raf.xmp").write_bytes(b"v1")
44+
45+ snapshot = checkout(dossier_archive, tmp_path / "local")
46+ (snapshot.dossier_local / "photo.raf.xmp").write_bytes(b"v2")
47+
48+ rapport = comparer(snapshot, snapshot.dossier_local)
49+
50+ assert _categorie(rapport, "photo.raf.xmp") == "normal"
51+ assert rapport.anomalies == []
52+ snapshot.manifest.conn.close()
53+
54+
55+def test_raw_proprietary_content_change_is_anomaly(tmp_path: Path) -> None:
56+ dossier_archive = tmp_path / "archive"
57+ dossier_archive.mkdir()
58+ (dossier_archive / "photo.raf").write_bytes(b"raw-original")
59+
60+ snapshot = checkout(dossier_archive, tmp_path / "local")
61+ (snapshot.dossier_local / "photo.raf").write_bytes(b"raw-modifie")
62+
63+ rapport = comparer(snapshot, snapshot.dossier_local)
64+
65+ assert _categorie(rapport, "photo.raf") == "anomalie"
66+ assert len(rapport.anomalies) == 1
67+ snapshot.manifest.conn.close()
68+
69+
70+def test_jpeg_metadata_only_edit_is_not_anomaly(tmp_path: Path) -> None:
71+ dossier_archive = tmp_path / "archive"
72+ dossier_archive.mkdir()
73+ (dossier_archive / "photo.jpg").write_bytes(base64.b64decode(_JPEG_1X1_BASE64))
74+
75+ snapshot = checkout(dossier_archive, tmp_path / "local")
76+ chemin_local = snapshot.dossier_local / "photo.jpg"
77+ subprocess.run( # noqa: S603, S607
78+ ["exiftool", "-Model=Fujifilm X100V", "-overwrite_original", str(chemin_local)],
79+ check=True,
80+ capture_output=True,
81+ )
82+ close_session() # la session de checkout a lu le fichier avant l'édition exiftool externe
83+
84+ rapport = comparer(snapshot, snapshot.dossier_local)
85+
86+ assert _categorie(rapport, "photo.jpg") == "normal"
87+ assert rapport.anomalies == []
88+ snapshot.manifest.conn.close()
89+
90+
91+def test_jpeg_pixel_change_is_anomaly(tmp_path: Path) -> None:
92+ dossier_archive = tmp_path / "archive"
93+ dossier_archive.mkdir()
94+ (dossier_archive / "photo.jpg").write_bytes(base64.b64decode(_JPEG_1X1_BASE64))
95+
96+ snapshot = checkout(dossier_archive, tmp_path / "local")
97+ # Remplace le contenu complet (simule une modification des pixels).
98+ (snapshot.dossier_local / "photo.jpg").write_bytes(b"donnees-image-completement-differentes")
99+
100+ rapport = comparer(snapshot, snapshot.dossier_local)
101+
102+ assert _categorie(rapport, "photo.jpg") == "anomalie"
103+ snapshot.manifest.conn.close()
104+
105+
106+def test_file_rename_detected_as_move_by_content(tmp_path: Path) -> None:
107+ dossier_archive = tmp_path / "archive"
108+ (dossier_archive / "jpeg").mkdir(parents=True)
109+ (dossier_archive / "jpeg" / "photo.jpg").write_bytes(b"contenu-jpeg")
110+
111+ snapshot = checkout(dossier_archive, tmp_path / "local")
112+ ancien = snapshot.dossier_local / "jpeg" / "photo.jpg"
113+ nouveau = snapshot.dossier_local / "photo.jpg" # promotion vers la racine
114+ ancien.rename(nouveau)
115+
116+ rapport = comparer(snapshot, snapshot.dossier_local)
117+
118+ changement = next(c for c in rapport.changements if c.categorie == "deplacement")
119+ assert str(changement.chemin) == "photo.jpg"
120+ assert str(changement.chemin_ancien) == "jpeg/photo.jpg"
121+ assert rapport.anomalies == []
122+ snapshot.manifest.conn.close()
123+
124+
125+def test_whole_folder_move_detected_as_move_for_every_file(tmp_path: Path) -> None:
126+ """FR-010 : le déplacement d'un dossier entier (ex. année -> catégorie thématique,
127+ cf. specs/004-categorisation-dossiers) n'est pas un cas à part : `comparer` ne
128+ raisonne que par chemin relatif à `dossier_archive`/`dossier_local`, donc déplacer
129+ plusieurs fichiers ensemble (simulant un sous-dossier entier renommé/déplacé) est
130+ détecté par le même mécanisme que pour un seul fichier, sans code dédié.
131+ Débloque specs/004-categorisation-dossiers T024b (déplacer effectivement le
132+ `dossier_archive` lui-même entre répertoires racine relève de l'orchestration
133+ d'un futur module, pas de `comparer`, qui n'a pas connaissance d'un « répertoire
134+ racine » — seulement de chemins relatifs à l'intérieur d'un dossier)."""
135+ dossier_archive = tmp_path / "archive"
136+ (dossier_archive / "raw").mkdir(parents=True)
137+ (dossier_archive / "raw" / "a.raf").write_bytes(b"contenu-a")
138+ (dossier_archive / "raw" / "b.raf").write_bytes(b"contenu-b")
139+
140+ snapshot = checkout(dossier_archive, tmp_path / "local")
141+
142+ # Simule le dossier "raw" entier renommé en "raw_ancien" (déplacement de plusieurs
143+ # fichiers d'un coup, comme le serait un sous-dossier entier relocalisé).
144+ (snapshot.dossier_local / "raw").rename(snapshot.dossier_local / "raw_ancien")
145+
146+ rapport = comparer(snapshot, snapshot.dossier_local)
147+
148+ deplacements = {
149+ c.chemin_ancien: c.chemin for c in rapport.changements if c.categorie == "deplacement"
150+ }
151+ assert deplacements == {
152+ Path("raw/a.raf"): Path("raw_ancien/a.raf"),
153+ Path("raw/b.raf"): Path("raw_ancien/b.raf"),
154+ }
155+ assert rapport.anomalies == []
156+ snapshot.manifest.conn.close()
new file mode 100644
@@ -0,0 +1,156 @@
1+"""Tests de la classification de reconciliation (T019, T024, T025, T029, T030)."""
2+
3+from __future__ import annotations
4+
5+import base64
6+import shutil
7+import subprocess
8+from pathlib import Path
9+
10+import pytest
11+from regine_core.archive.checkout import checkout
12+from regine_core.archive.reconciliation import comparer
13+from regine_core.metadata.exif import close_session
14+
15+_JPEG_1X1_BASE64 = (
16+ "/9j/4AAQSkZJRgABAQEAYABgAAD/2wBDAAMCAgICAgMCAgIDAwMDBAYEBAQEBAgGBgUGCQgKCgkICQkKDA8M"
17+ "CgsOCwkJDRENDg8QEBEQCgwSExIQEw8QEBD/2wBDAQMDAwQDBAgEBAgQCwkLEBAQEBAQEBAQEBAQEBAQEBAQ"
18+ "EBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBD/wAARCAABAAEDASIAAhEBAxEB/8QAFQABAQAA"
19+ "AAAAAAAAAAAAAAAAAAj/xAAUEAEAAAAAAAAAAAAAAAAAAAAA/8QAFQEBAQAAAAAAAAAAAAAAAAAAAAX/xAAU"
20+ "EQEAAAAAAAAAAAAAAAAAAAAA/9oADAMBAAIRAxEAPwCdABmX/9k="
21+)
22+
23+pytestmark = pytest.mark.skipif(shutil.which("exiftool") is None, reason="exiftool non installé")
24+
25+
26+@pytest.fixture(autouse=True)
27+def _close_shared_session():
28+ yield
29+ close_session()
30+
31+
32+def _categorie(rapport, chemin: str) -> str | None:
33+ for c in rapport.changements:
34+ if str(c.chemin) == chemin:
35+ return c.categorie
36+ return None
37+
38+
39+def test_sidecar_change_is_normal(tmp_path: Path) -> None:
40+ dossier_archive = tmp_path / "archive"
41+ dossier_archive.mkdir()
42+ (dossier_archive / "photo.raf").write_bytes(b"raw-inchange")
43+ (dossier_archive / "photo.raf.xmp").write_bytes(b"v1")
44+
45+ snapshot = checkout(dossier_archive, tmp_path / "local")
46+ (snapshot.dossier_local / "photo.raf.xmp").write_bytes(b"v2")
47+
48+ rapport = comparer(snapshot, snapshot.dossier_local)
49+
50+ assert _categorie(rapport, "photo.raf.xmp") == "normal"
51+ assert rapport.anomalies == []
52+ snapshot.manifest.conn.close()
53+
54+
55+def test_raw_proprietary_content_change_is_anomaly(tmp_path: Path) -> None:
56+ dossier_archive = tmp_path / "archive"
57+ dossier_archive.mkdir()
58+ (dossier_archive / "photo.raf").write_bytes(b"raw-original")
59+
60+ snapshot = checkout(dossier_archive, tmp_path / "local")
61+ (snapshot.dossier_local / "photo.raf").write_bytes(b"raw-modifie")
62+
63+ rapport = comparer(snapshot, snapshot.dossier_local)
64+
65+ assert _categorie(rapport, "photo.raf") == "anomalie"
66+ assert len(rapport.anomalies) == 1
67+ snapshot.manifest.conn.close()
68+
69+
70+def test_jpeg_metadata_only_edit_is_not_anomaly(tmp_path: Path) -> None:
71+ dossier_archive = tmp_path / "archive"
72+ dossier_archive.mkdir()
73+ (dossier_archive / "photo.jpg").write_bytes(base64.b64decode(_JPEG_1X1_BASE64))
74+
75+ snapshot = checkout(dossier_archive, tmp_path / "local")
76+ chemin_local = snapshot.dossier_local / "photo.jpg"
77+ subprocess.run( # noqa: S603, S607
78+ ["exiftool", "-Model=Fujifilm X100V", "-overwrite_original", str(chemin_local)],
79+ check=True,
80+ capture_output=True,
81+ )
82+ close_session() # la session de checkout a lu le fichier avant l'édition exiftool externe
83+
84+ rapport = comparer(snapshot, snapshot.dossier_local)
85+
86+ assert _categorie(rapport, "photo.jpg") == "normal"
87+ assert rapport.anomalies == []
88+ snapshot.manifest.conn.close()
89+
90+
91+def test_jpeg_pixel_change_is_anomaly(tmp_path: Path) -> None:
92+ dossier_archive = tmp_path / "archive"
93+ dossier_archive.mkdir()
94+ (dossier_archive / "photo.jpg").write_bytes(base64.b64decode(_JPEG_1X1_BASE64))
95+
96+ snapshot = checkout(dossier_archive, tmp_path / "local")
97+ # Remplace le contenu complet (simule une modification des pixels).
98+ (snapshot.dossier_local / "photo.jpg").write_bytes(b"donnees-image-completement-differentes")
99+
100+ rapport = comparer(snapshot, snapshot.dossier_local)
101+
102+ assert _categorie(rapport, "photo.jpg") == "anomalie"
103+ snapshot.manifest.conn.close()
104+
105+
106+def test_file_rename_detected_as_move_by_content(tmp_path: Path) -> None:
107+ dossier_archive = tmp_path / "archive"
108+ (dossier_archive / "jpeg").mkdir(parents=True)
109+ (dossier_archive / "jpeg" / "photo.jpg").write_bytes(b"contenu-jpeg")
110+
111+ snapshot = checkout(dossier_archive, tmp_path / "local")
112+ ancien = snapshot.dossier_local / "jpeg" / "photo.jpg"
113+ nouveau = snapshot.dossier_local / "photo.jpg" # promotion vers la racine
114+ ancien.rename(nouveau)
115+
116+ rapport = comparer(snapshot, snapshot.dossier_local)
117+
118+ changement = next(c for c in rapport.changements if c.categorie == "deplacement")
119+ assert str(changement.chemin) == "photo.jpg"
120+ assert str(changement.chemin_ancien) == "jpeg/photo.jpg"
121+ assert rapport.anomalies == []
122+ snapshot.manifest.conn.close()
123+
124+
125+def test_whole_folder_move_detected_as_move_for_every_file(tmp_path: Path) -> None:
126+ """FR-010 : le déplacement d'un dossier entier (ex. année -> catégorie thématique,
127+ cf. specs/004-categorisation-dossiers) n'est pas un cas à part : `comparer` ne
128+ raisonne que par chemin relatif à `dossier_archive`/`dossier_local`, donc déplacer
129+ plusieurs fichiers ensemble (simulant un sous-dossier entier renommé/déplacé) est
130+ détecté par le même mécanisme que pour un seul fichier, sans code dédié.
131+ Débloque specs/004-categorisation-dossiers T024b (déplacer effectivement le
132+ `dossier_archive` lui-même entre répertoires racine relève de l'orchestration
133+ d'un futur module, pas de `comparer`, qui n'a pas connaissance d'un « répertoire
134+ racine » — seulement de chemins relatifs à l'intérieur d'un dossier)."""
135+ dossier_archive = tmp_path / "archive"
136+ (dossier_archive / "raw").mkdir(parents=True)
137+ (dossier_archive / "raw" / "a.raf").write_bytes(b"contenu-a")
138+ (dossier_archive / "raw" / "b.raf").write_bytes(b"contenu-b")
139+
140+ snapshot = checkout(dossier_archive, tmp_path / "local")
141+
142+ # Simule le dossier "raw" entier renommé en "raw_ancien" (déplacement de plusieurs
143+ # fichiers d'un coup, comme le serait un sous-dossier entier relocalisé).
144+ (snapshot.dossier_local / "raw").rename(snapshot.dossier_local / "raw_ancien")
145+
146+ rapport = comparer(snapshot, snapshot.dossier_local)
147+
148+ deplacements = {
149+ c.chemin_ancien: c.chemin for c in rapport.changements if c.categorie == "deplacement"
150+ }
151+ assert deplacements == {
152+ Path("raw/a.raf"): Path("raw_ancien/a.raf"),
153+ Path("raw/b.raf"): Path("raw_ancien/b.raf"),
154+ }
155+ assert rapport.anomalies == []
156+ snapshot.manifest.conn.close()
added packages/regine-core/tests/unit/test_verrou.py +43 -0
new file mode 100644
@@ -0,0 +1,43 @@
1+"""Tests du verrouillage de dossier (T013)."""
2+
3+from __future__ import annotations
4+
5+from pathlib import Path
6+
7+import pytest
8+from regine_core.archive import verrou
9+from regine_core.archive.manifest import ouvrir_ou_creer
10+from regine_core.archive.verrou import DossierDejaVerrouilleError
11+
12+
13+@pytest.fixture
14+def handle(tmp_path: Path):
15+ h = ouvrir_ou_creer(tmp_path)
16+ yield h
17+ h.conn.close()
18+
19+
20+def test_lock_starts_unlocked(handle) -> None:
21+ assert verrou.verifier(handle) is False
22+
23+
24+def test_poser_locks_the_folder(handle) -> None:
25+ verrou.poser(handle)
26+
27+ assert verrou.verifier(handle) is True
28+
29+
30+def test_double_poser_is_refused(handle) -> None:
31+ verrou.poser(handle)
32+
33+ with pytest.raises(DossierDejaVerrouilleError):
34+ verrou.poser(handle)
35+
36+
37+def test_lever_unlocks_and_allows_repose(handle) -> None:
38+ verrou.poser(handle)
39+ verrou.lever(handle)
40+
41+ assert verrou.verifier(handle) is False
42+ verrou.poser(handle) # ne doit pas lever
43+ assert verrou.verifier(handle) is True
new file mode 100644
@@ -0,0 +1,43 @@
1+"""Tests du verrouillage de dossier (T013)."""
2+
3+from __future__ import annotations
4+
5+from pathlib import Path
6+
7+import pytest
8+from regine_core.archive import verrou
9+from regine_core.archive.manifest import ouvrir_ou_creer
10+from regine_core.archive.verrou import DossierDejaVerrouilleError
11+
12+
13+@pytest.fixture
14+def handle(tmp_path: Path):
15+ h = ouvrir_ou_creer(tmp_path)
16+ yield h
17+ h.conn.close()
18+
19+
20+def test_lock_starts_unlocked(handle) -> None:
21+ assert verrou.verifier(handle) is False
22+
23+
24+def test_poser_locks_the_folder(handle) -> None:
25+ verrou.poser(handle)
26+
27+ assert verrou.verifier(handle) is True
28+
29+
30+def test_double_poser_is_refused(handle) -> None:
31+ verrou.poser(handle)
32+
33+ with pytest.raises(DossierDejaVerrouilleError):
34+ verrou.poser(handle)
35+
36+
37+def test_lever_unlocks_and_allows_repose(handle) -> None:
38+ verrou.poser(handle)
39+ verrou.lever(handle)
40+
41+ assert verrou.verifier(handle) is False
42+ verrou.poser(handle) # ne doit pas lever
43+ assert verrou.verifier(handle) is True
modified specs/001-import-photos/tasks.md +6 -6
@@ -108,15 +108,15 @@ Ce module est le premier point d'intégration réel de trois autres specs déjà
108108 - [ ] T026 [P] [US3] Test unitaire de recherche de dossiers candidats par titre/date proche (`difflib`, cf. `research.md` § 4) dans `packages/regine-core/tests/unit/test_destination_candidats.py`
109109 - [ ] T027 [P] [US3] Test unitaire : la branche `nouveau_sous_dossier` réutilise le `RootLocation` du parent sans nouvel appel à `determine_root`, dans `test_destination_candidats.py`
110110 - [ ] T028 [US3] Test d'intégration voyage complet (parent + sous-dossier + fusion locale + désambiguïsation de boîtiers) dans `packages/regine-core/tests/integration/test_pipeline_voyage.py`
111-- [ ] T029 [US3] Test d'intégration de la fusion vers un dossier **déjà archivé** dans `test_pipeline_voyage.py` — **dépend de l'implémentation de `specs/005-checkout-reconciliation`** (pas encore de tasks.md) ; à activer une fois celle-ci disponible, ne bloque pas le reste de cette phase
111+- [ ] T029 [US3] Test d'intégration de la fusion vers un dossier **déjà archivé** dans `test_pipeline_voyage.py` — **`specs/005-checkout-reconciliation` est désormais implémentée (2026-09-19)**, cette tâche est activable ; ne bloque pas le reste de cette phase
112112
113113 ### Implementation for User Story 3
114114
115115 - [ ] T030 [US3] Étendre `resoudre_destination` : branches `nouveau_sous_dossier` (hérite le `RootLocation` du parent, FR-006 de specs/004) et `nouveau_parent` (FR-007), dans `destination.py` (dépend de T015)
116116 - [ ] T031 [US3] Implémenter `lister_dossiers_candidats(titre_partiel, date_proche)` dans `destination.py` (FR-008, cf. `research.md` § 4)
117117 - [ ] T032 [US3] Implémenter la branche `fusion` locale de `resoudre_destination` (dossier cible présent dans l'espace de travail local) dans `destination.py` (FR-009, partie locale)
118-- [ ] T033 [US3] Implémenter la branche `fusion` vers un dossier déjà archivé : appelle `regine_core.archive.checkout.checkout` (`specs/005-checkout-reconciliation`), dans `destination.py` — **dépend de l'implémentation de specs/005** ; sans elle, cette branche lève une erreur explicite indiquant la dépendance manquante plutôt que d'échouer silencieusement
119-- [ ] T034 [US3] Intégrer la désambiguïsation de boîtiers dans `copie.py` : regrouper les fichiers en collision de nom d'origine, appeler `regine_core.camera_profile.resolve_collision` (specs/002), gérer les groupes nécessitant un étiquetage manuel (FR-015/016) — **dépend de l'implémentation de specs/002**
118+- [ ] T033 [US3] Implémenter la branche `fusion` vers un dossier déjà archivé : appelle `regine_core.archive.checkout.checkout` (`specs/005-checkout-reconciliation`), dans `destination.py` — **`specs/005-checkout-reconciliation` est désormais implémentée (2026-09-19)**, cette tâche est activable
119+- [ ] T034 [US3] Intégrer la désambiguïsation de boîtiers dans `copie.py` : regrouper les fichiers en collision de nom d'origine, appeler `regine_core.camera_profile.resolve_collision` (specs/002), gérer les groupes nécessitant un étiquetage manuel (FR-015/016) — **`specs/002-profil-boitiers-optionnel` est désormais implémentée (2026-09-19)**, cette tâche est activable
120120 - [ ] T035 [US3] Étendre `packages/regine-cli/src/regine_cli/import_cmd.py` pour les 4 types de destination et la résolution interactive de collision de boîtiers
121121
122122 **Checkpoint**: User Stories 1, 2 et 3 fonctionnelles (la fusion vers un dossier déjà archivé reste conditionnée à `specs/005`, tout le reste est indépendant).
@@ -140,7 +140,7 @@ Ce module est le premier point d'intégration réel de trois autres specs déjà
140140 - **User Stories (Phase 3–5)** : dépendent toutes de Foundational.
141141 - US1 (P1) : aucune dépendance sur une autre user story de ce fichier ; dépend de `specs/004` (déjà disponible).
142142 - US2 (P2) : étend directement les fonctions de US1 (T014, T023/T024) — livrable séparément mais techniquement postérieure à US1.
143- - US3 (P3) : étend `destination.py` de US1 (T015 → T030) ; sa branche de fusion vers l'archive (T033) et sa désambiguïsation de boîtiers (T034) dépendent respectivement de `specs/005` et `specs/002`, non encore implémentées — le reste de US3 (structure parent/sous-dossier, fusion locale, recherche de candidats) est indépendant de ces deux specs.
143+ - US3 (P3) : étend `destination.py` de US1 (T015 → T030) ; sa branche de fusion vers l'archive (T033, `specs/005`) et sa désambiguïsation de boîtiers (T034, `specs/002`) sont désormais toutes deux activables, ces deux specs étant implémentées (2026-09-19).
144144 - **Polish (Phase 6)** : dépend des user stories livrées (au minimum US1).
145145
146146 ### Parallel Opportunities
@@ -176,11 +176,11 @@ Task: "Test unitaire construction nom dossier dans packages/regine-core/tests/un
176176 1. Setup + Foundational → socle prêt.
177177 2. US1 → import simple (MVP) → valider Scénario 1.
178178 3. US2 → découpage multi-jours → valider Scénario 2.
179-4. US3 → voyage multi-étapes → valider Scénario 3, à l'exception de la fusion vers un dossier déjà archivé (T033) qui attend `specs/005-checkout-reconciliation`, et de la désambiguïsation de boîtiers (T034) qui attend `specs/002-profil-boitiers-optionnel`.
179+4. US3 → voyage multi-étapes → valider Scénario 3, y compris la fusion vers un dossier déjà archivé (T033) et la désambiguïsation de boîtiers (T034), toutes deux activables (`specs/005` et `specs/002` implémentées le 2026-09-19).
180180
181181 ## Notes
182182
183183 - [P] = fichiers différents, sans dépendance non résolue.
184-- Chaque user story est livrable et testable indépendamment ; seules deux tâches précises de US3 (T033, T034) restent conditionnées à l'implémentation d'autres specs déjà planifiées.
184+- Chaque user story est livrable et testable indépendamment ; T033/T034 (US3) dépendaient de `specs/005`/`specs/002`, toutes deux implémentées (2026-09-19) — plus aucune dépendance externe non résolue dans ce fichier.
185185 - Committer après chaque tâche ou groupe logique de tâches.
186186 - Ne pas recréer le squelette monorepo ni `regine_core.dossier.root`/`regine_core.config` : ils viennent de `specs/004-categorisation-dossiers/tasks.md`, à étendre si besoin, jamais dupliquer.
@@ -108,15 +108,15 @@ Ce module est le premier point d'intégration réel de trois autres specs déjà
108 - [ ] T026 [P] [US3] Test unitaire de recherche de dossiers candidats par titre/date proche (`difflib`, cf. `research.md` § 4) dans `packages/regine-core/tests/unit/test_destination_candidats.py`108 - [ ] T026 [P] [US3] Test unitaire de recherche de dossiers candidats par titre/date proche (`difflib`, cf. `research.md` § 4) dans `packages/regine-core/tests/unit/test_destination_candidats.py`
109 - [ ] T027 [P] [US3] Test unitaire : la branche `nouveau_sous_dossier` réutilise le `RootLocation` du parent sans nouvel appel à `determine_root`, dans `test_destination_candidats.py`109 - [ ] T027 [P] [US3] Test unitaire : la branche `nouveau_sous_dossier` réutilise le `RootLocation` du parent sans nouvel appel à `determine_root`, dans `test_destination_candidats.py`
110 - [ ] T028 [US3] Test d'intégration voyage complet (parent + sous-dossier + fusion locale + désambiguïsation de boîtiers) dans `packages/regine-core/tests/integration/test_pipeline_voyage.py`110 - [ ] T028 [US3] Test d'intégration voyage complet (parent + sous-dossier + fusion locale + désambiguïsation de boîtiers) dans `packages/regine-core/tests/integration/test_pipeline_voyage.py`
111-- [ ] T029 [US3] Test d'intégration de la fusion vers un dossier **déjà archivé** dans `test_pipeline_voyage.py` — **dépend de l'implémentation de `specs/005-checkout-reconciliation`** (pas encore de tasks.md) ; à activer une fois celle-ci disponible, ne bloque pas le reste de cette phase111+- [ ] T029 [US3] Test d'intégration de la fusion vers un dossier **déjà archivé** dans `test_pipeline_voyage.py` — **`specs/005-checkout-reconciliation` est désormais implémentée (2026-09-19)**, cette tâche est activable ; ne bloque pas le reste de cette phase
112 112
113 ### Implementation for User Story 3113 ### Implementation for User Story 3
114 114
115 - [ ] T030 [US3] Étendre `resoudre_destination` : branches `nouveau_sous_dossier` (hérite le `RootLocation` du parent, FR-006 de specs/004) et `nouveau_parent` (FR-007), dans `destination.py` (dépend de T015)115 - [ ] T030 [US3] Étendre `resoudre_destination` : branches `nouveau_sous_dossier` (hérite le `RootLocation` du parent, FR-006 de specs/004) et `nouveau_parent` (FR-007), dans `destination.py` (dépend de T015)
116 - [ ] T031 [US3] Implémenter `lister_dossiers_candidats(titre_partiel, date_proche)` dans `destination.py` (FR-008, cf. `research.md` § 4)116 - [ ] T031 [US3] Implémenter `lister_dossiers_candidats(titre_partiel, date_proche)` dans `destination.py` (FR-008, cf. `research.md` § 4)
117 - [ ] T032 [US3] Implémenter la branche `fusion` locale de `resoudre_destination` (dossier cible présent dans l'espace de travail local) dans `destination.py` (FR-009, partie locale)117 - [ ] T032 [US3] Implémenter la branche `fusion` locale de `resoudre_destination` (dossier cible présent dans l'espace de travail local) dans `destination.py` (FR-009, partie locale)
118-- [ ] T033 [US3] Implémenter la branche `fusion` vers un dossier déjà archivé : appelle `regine_core.archive.checkout.checkout` (`specs/005-checkout-reconciliation`), dans `destination.py` — **dépend de l'implémentation de specs/005** ; sans elle, cette branche lève une erreur explicite indiquant la dépendance manquante plutôt que d'échouer silencieusement118+- [ ] T033 [US3] Implémenter la branche `fusion` vers un dossier déjà archivé : appelle `regine_core.archive.checkout.checkout` (`specs/005-checkout-reconciliation`), dans `destination.py` — **`specs/005-checkout-reconciliation` est désormais implémentée (2026-09-19)**, cette tâche est activable
119-- [ ] T034 [US3] Intégrer la désambiguïsation de boîtiers dans `copie.py` : regrouper les fichiers en collision de nom d'origine, appeler `regine_core.camera_profile.resolve_collision` (specs/002), gérer les groupes nécessitant un étiquetage manuel (FR-015/016) — **dépend de l'implémentation de specs/002**119+- [ ] T034 [US3] Intégrer la désambiguïsation de boîtiers dans `copie.py` : regrouper les fichiers en collision de nom d'origine, appeler `regine_core.camera_profile.resolve_collision` (specs/002), gérer les groupes nécessitant un étiquetage manuel (FR-015/016) — **`specs/002-profil-boitiers-optionnel` est désormais implémentée (2026-09-19)**, cette tâche est activable
120 - [ ] T035 [US3] Étendre `packages/regine-cli/src/regine_cli/import_cmd.py` pour les 4 types de destination et la résolution interactive de collision de boîtiers120 - [ ] T035 [US3] Étendre `packages/regine-cli/src/regine_cli/import_cmd.py` pour les 4 types de destination et la résolution interactive de collision de boîtiers
121 121
122 **Checkpoint**: User Stories 1, 2 et 3 fonctionnelles (la fusion vers un dossier déjà archivé reste conditionnée à `specs/005`, tout le reste est indépendant).122 **Checkpoint**: User Stories 1, 2 et 3 fonctionnelles (la fusion vers un dossier déjà archivé reste conditionnée à `specs/005`, tout le reste est indépendant).
@@ -140,7 +140,7 @@ Ce module est le premier point d'intégration réel de trois autres specs déjà
140 - **User Stories (Phase 3–5)** : dépendent toutes de Foundational.140 - **User Stories (Phase 3–5)** : dépendent toutes de Foundational.
141 - US1 (P1) : aucune dépendance sur une autre user story de ce fichier ; dépend de `specs/004` (déjà disponible).141 - US1 (P1) : aucune dépendance sur une autre user story de ce fichier ; dépend de `specs/004` (déjà disponible).
142 - US2 (P2) : étend directement les fonctions de US1 (T014, T023/T024) — livrable séparément mais techniquement postérieure à US1.142 - US2 (P2) : étend directement les fonctions de US1 (T014, T023/T024) — livrable séparément mais techniquement postérieure à US1.
143- - US3 (P3) : étend `destination.py` de US1 (T015 → T030) ; sa branche de fusion vers l'archive (T033) et sa désambiguïsation de boîtiers (T034) dépendent respectivement de `specs/005` et `specs/002`, non encore implémentées — le reste de US3 (structure parent/sous-dossier, fusion locale, recherche de candidats) est indépendant de ces deux specs.143+ - US3 (P3) : étend `destination.py` de US1 (T015 → T030) ; sa branche de fusion vers l'archive (T033, `specs/005`) et sa désambiguïsation de boîtiers (T034, `specs/002`) sont désormais toutes deux activables, ces deux specs étant implémentées (2026-09-19).
144 - **Polish (Phase 6)** : dépend des user stories livrées (au minimum US1).144 - **Polish (Phase 6)** : dépend des user stories livrées (au minimum US1).
145 145
146 ### Parallel Opportunities146 ### Parallel Opportunities
@@ -176,11 +176,11 @@ Task: "Test unitaire construction nom dossier dans packages/regine-core/tests/un
176 1. Setup + Foundational → socle prêt.176 1. Setup + Foundational → socle prêt.
177 2. US1 → import simple (MVP) → valider Scénario 1.177 2. US1 → import simple (MVP) → valider Scénario 1.
178 3. US2 → découpage multi-jours → valider Scénario 2.178 3. US2 → découpage multi-jours → valider Scénario 2.
179-4. US3 → voyage multi-étapes → valider Scénario 3, à l'exception de la fusion vers un dossier déjà archivé (T033) qui attend `specs/005-checkout-reconciliation`, et de la désambiguïsation de boîtiers (T034) qui attend `specs/002-profil-boitiers-optionnel`.179+4. US3 → voyage multi-étapes → valider Scénario 3, y compris la fusion vers un dossier déjà archivé (T033) et la désambiguïsation de boîtiers (T034), toutes deux activables (`specs/005` et `specs/002` implémentées le 2026-09-19).
180 180
181 ## Notes181 ## Notes
182 182
183 - [P] = fichiers différents, sans dépendance non résolue.183 - [P] = fichiers différents, sans dépendance non résolue.
184-- Chaque user story est livrable et testable indépendamment ; seules deux tâches précises de US3 (T033, T034) restent conditionnées à l'implémentation d'autres specs déjà planifiées.184+- Chaque user story est livrable et testable indépendamment ; T033/T034 (US3) dépendaient de `specs/005`/`specs/002`, toutes deux implémentées (2026-09-19) — plus aucune dépendance externe non résolue dans ce fichier.
185 - Committer après chaque tâche ou groupe logique de tâches.185 - Committer après chaque tâche ou groupe logique de tâches.
186 - Ne pas recréer le squelette monorepo ni `regine_core.dossier.root`/`regine_core.config` : ils viennent de `specs/004-categorisation-dossiers/tasks.md`, à étendre si besoin, jamais dupliquer.186 - Ne pas recréer le squelette monorepo ni `regine_core.dossier.root`/`regine_core.config` : ils viennent de `specs/004-categorisation-dossiers/tasks.md`, à étendre si besoin, jamais dupliquer.
modified specs/004-categorisation-dossiers/tasks.md +1 -1
@@ -114,7 +114,7 @@ Aucun code n'existe encore dans ce dépôt (seuls `docs/`, `specs/`, `.specify/`
114114 **Statut** : **Mise à jour du 2026-09-19** — le mécanisme de détection de déplacement par somme de contrôle a désormais sa propre spec et son propre plan (`specs/005-checkout-reconciliation`, dont FR-010 couvre exactement ce cas). Ce n'est plus une dépendance bloquante au niveau spécification : seulement une dépendance d'ordonnancement (l'implémentation de `specs/005` doit précéder la validation de bout en bout de cette user story).
115115
116116 - [X] T024 [US4] Documenter (docstring) dans `packages/regine-core/src/regine_core/dossier/root.py` qu'il n'existe aucune opération d'écriture directe sur `RootLocation` : tout changement de répertoire racine passe exclusivement par `regine_core.archive.reconciliation.comparer` (`specs/005-checkout-reconciliation`), qui classe un déplacement de dossier entier comme `deplacement` sans code supplémentaire ici
117-- [ ] T024b [US4] Test d'intégration (à exécuter une fois `specs/005-checkout-reconciliation` implémentée) : déplacer un dossier de travail entre deux répertoires racine (année → catégorie) et vérifier que `regine_core.archive.reconciliation.comparer` le classe `deplacement`, dans `packages/regine-core/tests/integration/test_root_recategorisation.py` — **toujours en attente, `specs/005` pas encore implémentée**
117+- [ ] T024b [US4] Test d'intégration : déplacer un dossier de travail entre deux répertoires racine (année → catégorie) et vérifier que `regine_core.archive.reconciliation.comparer` le classe `deplacement`, dans `packages/regine-core/tests/integration/test_root_recategorisation.py` — **`specs/005-checkout-reconciliation` est désormais implémentée (2026-09-19) ; `comparer` classe déjà ce cas (validé par `test_whole_folder_move_detected_as_move_for_every_file` côté specs/005) ; cette tâche reste à exécuter pour ajouter le test explicitement côté specs/004**
118118
119119 **Checkpoint**: US4 documentée et prête à être validée dès que `specs/005-checkout-reconciliation` est implémentée — plus aucune spécification manquante.
120120
@@ -114,7 +114,7 @@ Aucun code n'existe encore dans ce dépôt (seuls `docs/`, `specs/`, `.specify/`
114 **Statut** : **Mise à jour du 2026-09-19** — le mécanisme de détection de déplacement par somme de contrôle a désormais sa propre spec et son propre plan (`specs/005-checkout-reconciliation`, dont FR-010 couvre exactement ce cas). Ce n'est plus une dépendance bloquante au niveau spécification : seulement une dépendance d'ordonnancement (l'implémentation de `specs/005` doit précéder la validation de bout en bout de cette user story).114 **Statut** : **Mise à jour du 2026-09-19** — le mécanisme de détection de déplacement par somme de contrôle a désormais sa propre spec et son propre plan (`specs/005-checkout-reconciliation`, dont FR-010 couvre exactement ce cas). Ce n'est plus une dépendance bloquante au niveau spécification : seulement une dépendance d'ordonnancement (l'implémentation de `specs/005` doit précéder la validation de bout en bout de cette user story).
115 115
116 - [X] T024 [US4] Documenter (docstring) dans `packages/regine-core/src/regine_core/dossier/root.py` qu'il n'existe aucune opération d'écriture directe sur `RootLocation` : tout changement de répertoire racine passe exclusivement par `regine_core.archive.reconciliation.comparer` (`specs/005-checkout-reconciliation`), qui classe un déplacement de dossier entier comme `deplacement` sans code supplémentaire ici116 - [X] T024 [US4] Documenter (docstring) dans `packages/regine-core/src/regine_core/dossier/root.py` qu'il n'existe aucune opération d'écriture directe sur `RootLocation` : tout changement de répertoire racine passe exclusivement par `regine_core.archive.reconciliation.comparer` (`specs/005-checkout-reconciliation`), qui classe un déplacement de dossier entier comme `deplacement` sans code supplémentaire ici
117-- [ ] T024b [US4] Test d'intégration (à exécuter une fois `specs/005-checkout-reconciliation` implémentée) : déplacer un dossier de travail entre deux répertoires racine (année → catégorie) et vérifier que `regine_core.archive.reconciliation.comparer` le classe `deplacement`, dans `packages/regine-core/tests/integration/test_root_recategorisation.py` — **toujours en attente, `specs/005` pas encore implémentée**117+- [ ] T024b [US4] Test d'intégration : déplacer un dossier de travail entre deux répertoires racine (année → catégorie) et vérifier que `regine_core.archive.reconciliation.comparer` le classe `deplacement`, dans `packages/regine-core/tests/integration/test_root_recategorisation.py` — **`specs/005-checkout-reconciliation` est désormais implémentée (2026-09-19) ; `comparer` classe déjà ce cas (validé par `test_whole_folder_move_detected_as_move_for_every_file` côté specs/005) ; cette tâche reste à exécuter pour ajouter le test explicitement côté specs/004**
118 118
119 **Checkpoint**: US4 documentée et prête à être validée dès que `specs/005-checkout-reconciliation` est implémentée — plus aucune spécification manquante.119 **Checkpoint**: US4 documentée et prête à être validée dès que `specs/005-checkout-reconciliation` est implémentée — plus aucune spécification manquante.
120 120
modified specs/005-checkout-reconciliation/contracts/regine-core-api.md +18 -12
@@ -1,6 +1,6 @@
11 # Contrat d'API interne : `regine_core.integrity` / `regine_core.archive`
22
3-Objets structurés, jamais de texte à parser (Principe VI). Consommée par `regine-cli` (`contracts/cli-checkout-reconcile.md`) et, plus tard, par le point d'intégration de `specs/001-import-photos` (FR-009) et `specs/004-categorisation-dossiers` (User Story 4).
3+Objets structurés, jamais de texte à parser (Principe VI). Consommée par `regine-cli` (`contracts/cli-checkout-reconcile.md`) et, une fois leurs propres tâches exécutées, par `specs/001-import-photos` (FR-009) et `specs/004-categorisation-dossiers` (User Story 4) — cf. § Point d'intégration ci-dessous.
44
55 ## `regine_core.integrity.hash.hash_fichier_entier(chemin: Path) -> str`
66
@@ -10,13 +10,17 @@ SHA-256 du fichier entier (stdlib `hashlib`).
1010
1111 `None` si le format n'est pas DNG/TIFF/JPEG. Sinon, délègue à `regine_core.metadata.exif.read_image_data_hash` (cf. research.md § 5).
1212
13-## `regine_core.integrity.anomalie.est_maitre_modifie(format: str, ref: FichierManifeste, actuel: FichierManifeste) -> bool`
13+## `regine_core.integrity.anomalie.est_maitre_modifie(extension: str, ref: Empreinte, actuel: Empreinte) -> bool`
1414
15-Implémente le Principe I : pour un RAW propriétaire, compare `hash_fichier_entier` seul. Pour DNG/TIFF/JPEG, compare `hash_image_only` (si `None` des deux côtés, replie sur `hash_fichier_entier`). Retourne `True` uniquement si le contenu pertinent a changé.
15+**Signature finale (2026-09-19)** : prend directement deux `Empreinte` (`hash_fichier_entier`, `hash_image_only`) plutôt qu'un type `FichierManifeste` générique — plus simple à tester unitairement, sans dépendre du manifeste. Implémente le Principe I : pour un RAW propriétaire, compare `hash_fichier_entier` seul. Pour DNG/TIFF/JPEG, compare `hash_image_only` (si `None` des deux côtés, replie sur `hash_fichier_entier`). Retourne `True` uniquement si le contenu pertinent a changé.
1616
1717 ## `regine_core.archive.manifest.ouvrir_ou_creer(dossier: Path) -> ManifestHandle`
1818
19-Ouvre le manifeste SQLite à la racine de `dossier` (le crée si absent — premier checkout). Vérifie `PRAGMA user_version` (cf. research.md § 2) ; lève une erreur explicite si la version structurelle du manifeste est plus récente que ce que le code sait lire.
19+Ouvre le manifeste SQLite à la racine de `dossier` (le crée si absent — premier checkout). Nom de fichier : `.regine-manifest.sqlite3`. Vérifie `PRAGMA user_version` (cf. research.md § 2) ; lève `VersionStructurelleNonSupporteeError` si la version structurelle du manifeste est plus récente que ce que le code sait lire, ou `FormatManifesteInvalideError` si le fichier n'est pas un manifeste Régine (`application_id` différent).
20+
21+## Classification de réconciliation : distinction sidecar / fichier maître (précision 2026-09-19)
22+
23+`comparer()` exempte de toute détection d'anomalie les fichiers dont l'extension est `xmp`, `dop` ou `acr` (`EXTENSIONS_SIDECAR` dans `reconciliation.py`) — un changement sur ces extensions est toujours classé `normal`. Tout autre fichier dont le hash a changé passe par `est_maitre_modifie`. **Limite connue, documentée plutôt que masquée** : un export dérivé archivé volontairement (cf. specs/001-import-photos, cas « nouveau fichier → archiver aussi ») porte généralement une extension image (ex. `.jpg`) et serait donc soumis à la même détection d'anomalie qu'un véritable fichier maître si son contenu change après coup — le manifeste ne distingue pas aujourd'hui un fichier maître d'un dérivé archivé par choix. Cette distinction plus fine relève d'un futur raffinement (ex. un champ de rôle dans le manifeste), pas de ce plan.
2024
2125 ## `regine_core.archive.verrou.poser(manifest: ManifestHandle) -> None` / `verifier(manifest) -> bool` / `lever(manifest) -> None`
2226
@@ -26,17 +30,19 @@ Gèrent la table `verrou` (FR-003/004/016). `poser` échoue (exception dédiée)
2630
2731 Copie vérifiée (FR-001/015/019/020), pose le verrou, retourne le snapshot du manifeste au moment du checkout. Lève une exception dédiée si le dossier est déjà verrouillé (FR-004).
2832
29-## `regine_core.archive.reconciliation.comparer(manifest: ManifestSnapshot, copie_locale: Path) -> RapportReconciliation`
33+## `regine_core.archive.reconciliation.comparer(snapshot: ManifestSnapshot, copie_locale: Path) -> RapportReconciliation`
34+
35+**Signature finale** : prend un `ManifestSnapshot` complet (pas seulement un `ManifestHandle`) — nécessaire pour connaître `dossier_archive`, `dossier_local` et `formats` (périmètre d'un checkout partiel, FR-020). Implémente FR-006/007/008/009/010/011/012 : construit les index hash→chemin (manifeste et copie locale, cf. research.md § 4), classe chaque fichier en `normal`/`anomalie`/`deplacement`/`suppression`/`nouveau`. Un fichier du manifeste hors du périmètre d'un checkout partiel (`regine_core.archive.checkout.dans_perimetre`) est silencieusement exclu de la comparaison — jamais signalé comme supprimé.
3036
31-Implémente FR-006/007/008/009/010/011/012 : construit les index hash→chemin (manifeste et copie locale, cf. research.md § 4), classe chaque fichier en `normal`/`anomalie`/`deplacement`/`suppression`/`nouveau`.
37+## `regine_core.archive.reconciliation.archiver(snapshot: ManifestSnapshot, rapport: RapportReconciliation, decisions: DecisionsUtilisateur) -> None`
3238
33-## `regine_core.archive.reconciliation.archiver(dossier_archive: Path, rapport: RapportReconciliation, decisions: DecisionsUtilisateur) -> None`
39+**Signature finale** : prend `snapshot` (pas `dossier_archive` seul), pour la même raison que `comparer`. `DecisionsUtilisateur` regroupe une confirmation globale pour les changements normaux/déplacements/suppressions (`confirmer_changements_normaux`), une résolution par anomalie (`resolutions_anomalies: dict[chemin, "confirmer"|"restaurer"]`), et un ensemble de nouveaux fichiers à archiver (`nouveaux_fichiers_a_archiver`). Écrit sur l'archive uniquement les changements couverts par ces décisions (FR-013/014), vérifie chaque transfert (FR-015), met à jour le manifeste et lève le verrou uniquement si tous les changements du rapport ont été traités (confirmés, restaurés, ou nouveaux fichiers explicitement laissés en local) — une anomalie non résolue conserve le verrou (FR-016), cf. `test_anomaly_decision_does_not_block_already_confirmed_normal_changes`.
3440
35-Écrit sur l'archive uniquement les changements couverts par `decisions` (FR-013/014), vérifie chaque transfert (FR-015), met à jour le manifeste et lève le verrou une fois toutes les écritures de la session terminées (FR-016). Ne DOIT jamais être appelée sans qu'un « point avant archive » ait été présenté et confirmé côté appelant.
41+## Point d'intégration côté façades — désormais implémenté (mise à jour 2026-09-19)
3642
37-## Point d'intégration côté façades (futur, hors périmètre de ce plan)
43+- `specs/001-import-photos` FR-009 (fusion vers un dossier déjà archivé) DEVRA appeler `checkout` sur le dossier ciblé avant d'y intégrer les nouveaux fichiers — cf. `specs/001-import-photos/tasks.md` T033, désormais implémentable (dépendance levée).
44+- `specs/004-categorisation-dossiers` User Story 4 (recatégorisation a posteriori) s'appuie sur `comparer`, qui classe déjà un déplacement de plusieurs fichiers (simulant un dossier entier) comme `deplacement`, sans code supplémentaire côté `specs/004` — cf. `specs/004-categorisation-dossiers/tasks.md` T024b, désormais implémentable. **Limite à noter** : `comparer` raisonne uniquement par chemin relatif à l'intérieur d'un `dossier_archive` fixe ; un déplacement du dossier archive lui-même entre deux répertoires racine (ex. `2026/` → `mariage/`) reste une opération d'orchestration au niveau du futur module `import`/`dossier` (checkout depuis l'ancien emplacement, réarchivage vers le nouveau), pas quelque chose que `comparer` détecte tout seul en comparant deux chemins absolus différents.
3845
39-- `specs/001-import-photos` FR-009 (fusion vers un dossier déjà archivé) DEVRA appeler `checkout` sur le dossier ciblé avant d'y intégrer les nouveaux fichiers, plutôt que de lever `ChecoutNonDisponibleError` comme actuellement documenté dans son plan.
40-- `specs/004-categorisation-dossiers` User Story 4 (recatégorisation a posteriori) DEVRA s'appuyer sur `comparer` : un dossier déplacé entre répertoires racine y est déjà classé `deplacement` (FR-010 de cette spec), sans code supplémentaire à écrire côté `specs/004`.
46+## Bug réel trouvé et corrigé en cours d'implémentation (2026-09-19)
4147
42-Ces deux points d'intégration ne sont pas implémentés par ce plan ; ils sont documentés ici pour que leur future implémentation consomme cette API sans la redéfinir.
48+`archive_cmd._cmd_reconcile` retournait sans appeler `verrou.lever()` quand la réconciliation ne trouvait aucun changement (`rapport.changements` vide), laissant le dossier verrouillé indéfiniment après une réconciliation « à vide ». Corrigé : le verrou est désormais levé dans ce cas aussi. Trouvé par le test d'intégration T032 (`test_checkout_accepted_again_after_reconciliation`).
@@ -1,6 +1,6 @@
1 # Contrat d'API interne : `regine_core.integrity` / `regine_core.archive`1 # Contrat d'API interne : `regine_core.integrity` / `regine_core.archive`
2 2
3-Objets structurés, jamais de texte à parser (Principe VI). Consommée par `regine-cli` (`contracts/cli-checkout-reconcile.md`) et, plus tard, par le point d'intégration de `specs/001-import-photos` (FR-009) et `specs/004-categorisation-dossiers` (User Story 4).3+Objets structurés, jamais de texte à parser (Principe VI). Consommée par `regine-cli` (`contracts/cli-checkout-reconcile.md`) et, une fois leurs propres tâches exécutées, par `specs/001-import-photos` (FR-009) et `specs/004-categorisation-dossiers` (User Story 4) — cf. § Point d'intégration ci-dessous.
4 4
5 ## `regine_core.integrity.hash.hash_fichier_entier(chemin: Path) -> str`5 ## `regine_core.integrity.hash.hash_fichier_entier(chemin: Path) -> str`
6 6
@@ -10,13 +10,17 @@ SHA-256 du fichier entier (stdlib `hashlib`).
10 10
11 `None` si le format n'est pas DNG/TIFF/JPEG. Sinon, délègue à `regine_core.metadata.exif.read_image_data_hash` (cf. research.md § 5).11 `None` si le format n'est pas DNG/TIFF/JPEG. Sinon, délègue à `regine_core.metadata.exif.read_image_data_hash` (cf. research.md § 5).
12 12
13-## `regine_core.integrity.anomalie.est_maitre_modifie(format: str, ref: FichierManifeste, actuel: FichierManifeste) -> bool`13+## `regine_core.integrity.anomalie.est_maitre_modifie(extension: str, ref: Empreinte, actuel: Empreinte) -> bool`
14 14
15-Implémente le Principe I : pour un RAW propriétaire, compare `hash_fichier_entier` seul. Pour DNG/TIFF/JPEG, compare `hash_image_only` (si `None` des deux côtés, replie sur `hash_fichier_entier`). Retourne `True` uniquement si le contenu pertinent a changé.15+**Signature finale (2026-09-19)** : prend directement deux `Empreinte` (`hash_fichier_entier`, `hash_image_only`) plutôt qu'un type `FichierManifeste` générique — plus simple à tester unitairement, sans dépendre du manifeste. Implémente le Principe I : pour un RAW propriétaire, compare `hash_fichier_entier` seul. Pour DNG/TIFF/JPEG, compare `hash_image_only` (si `None` des deux côtés, replie sur `hash_fichier_entier`). Retourne `True` uniquement si le contenu pertinent a changé.
16 16
17 ## `regine_core.archive.manifest.ouvrir_ou_creer(dossier: Path) -> ManifestHandle`17 ## `regine_core.archive.manifest.ouvrir_ou_creer(dossier: Path) -> ManifestHandle`
18 18
19-Ouvre le manifeste SQLite à la racine de `dossier` (le crée si absent — premier checkout). Vérifie `PRAGMA user_version` (cf. research.md § 2) ; lève une erreur explicite si la version structurelle du manifeste est plus récente que ce que le code sait lire.19+Ouvre le manifeste SQLite à la racine de `dossier` (le crée si absent — premier checkout). Nom de fichier : `.regine-manifest.sqlite3`. Vérifie `PRAGMA user_version` (cf. research.md § 2) ; lève `VersionStructurelleNonSupporteeError` si la version structurelle du manifeste est plus récente que ce que le code sait lire, ou `FormatManifesteInvalideError` si le fichier n'est pas un manifeste Régine (`application_id` différent).
20+
21+## Classification de réconciliation : distinction sidecar / fichier maître (précision 2026-09-19)
22+
23+`comparer()` exempte de toute détection d'anomalie les fichiers dont l'extension est `xmp`, `dop` ou `acr` (`EXTENSIONS_SIDECAR` dans `reconciliation.py`) — un changement sur ces extensions est toujours classé `normal`. Tout autre fichier dont le hash a changé passe par `est_maitre_modifie`. **Limite connue, documentée plutôt que masquée** : un export dérivé archivé volontairement (cf. specs/001-import-photos, cas « nouveau fichier → archiver aussi ») porte généralement une extension image (ex. `.jpg`) et serait donc soumis à la même détection d'anomalie qu'un véritable fichier maître si son contenu change après coup — le manifeste ne distingue pas aujourd'hui un fichier maître d'un dérivé archivé par choix. Cette distinction plus fine relève d'un futur raffinement (ex. un champ de rôle dans le manifeste), pas de ce plan.
20 24
21 ## `regine_core.archive.verrou.poser(manifest: ManifestHandle) -> None` / `verifier(manifest) -> bool` / `lever(manifest) -> None`25 ## `regine_core.archive.verrou.poser(manifest: ManifestHandle) -> None` / `verifier(manifest) -> bool` / `lever(manifest) -> None`
22 26
@@ -26,17 +30,19 @@ Gèrent la table `verrou` (FR-003/004/016). `poser` échoue (exception dédiée)
26 30
27 Copie vérifiée (FR-001/015/019/020), pose le verrou, retourne le snapshot du manifeste au moment du checkout. Lève une exception dédiée si le dossier est déjà verrouillé (FR-004).31 Copie vérifiée (FR-001/015/019/020), pose le verrou, retourne le snapshot du manifeste au moment du checkout. Lève une exception dédiée si le dossier est déjà verrouillé (FR-004).
28 32
29-## `regine_core.archive.reconciliation.comparer(manifest: ManifestSnapshot, copie_locale: Path) -> RapportReconciliation`33+## `regine_core.archive.reconciliation.comparer(snapshot: ManifestSnapshot, copie_locale: Path) -> RapportReconciliation`
34+
35+**Signature finale** : prend un `ManifestSnapshot` complet (pas seulement un `ManifestHandle`) — nécessaire pour connaître `dossier_archive`, `dossier_local` et `formats` (périmètre d'un checkout partiel, FR-020). Implémente FR-006/007/008/009/010/011/012 : construit les index hash→chemin (manifeste et copie locale, cf. research.md § 4), classe chaque fichier en `normal`/`anomalie`/`deplacement`/`suppression`/`nouveau`. Un fichier du manifeste hors du périmètre d'un checkout partiel (`regine_core.archive.checkout.dans_perimetre`) est silencieusement exclu de la comparaison — jamais signalé comme supprimé.
30 36
31-Implémente FR-006/007/008/009/010/011/012 : construit les index hash→chemin (manifeste et copie locale, cf. research.md § 4), classe chaque fichier en `normal`/`anomalie`/`deplacement`/`suppression`/`nouveau`.37+## `regine_core.archive.reconciliation.archiver(snapshot: ManifestSnapshot, rapport: RapportReconciliation, decisions: DecisionsUtilisateur) -> None`
32 38
33-## `regine_core.archive.reconciliation.archiver(dossier_archive: Path, rapport: RapportReconciliation, decisions: DecisionsUtilisateur) -> None`39+**Signature finale** : prend `snapshot` (pas `dossier_archive` seul), pour la même raison que `comparer`. `DecisionsUtilisateur` regroupe une confirmation globale pour les changements normaux/déplacements/suppressions (`confirmer_changements_normaux`), une résolution par anomalie (`resolutions_anomalies: dict[chemin, "confirmer"|"restaurer"]`), et un ensemble de nouveaux fichiers à archiver (`nouveaux_fichiers_a_archiver`). Écrit sur l'archive uniquement les changements couverts par ces décisions (FR-013/014), vérifie chaque transfert (FR-015), met à jour le manifeste et lève le verrou uniquement si tous les changements du rapport ont été traités (confirmés, restaurés, ou nouveaux fichiers explicitement laissés en local) — une anomalie non résolue conserve le verrou (FR-016), cf. `test_anomaly_decision_does_not_block_already_confirmed_normal_changes`.
34 40
35-Écrit sur l'archive uniquement les changements couverts par `decisions` (FR-013/014), vérifie chaque transfert (FR-015), met à jour le manifeste et lève le verrou une fois toutes les écritures de la session terminées (FR-016). Ne DOIT jamais être appelée sans qu'un « point avant archive » ait été présenté et confirmé côté appelant.41+## Point d'intégration côté façades — désormais implémenté (mise à jour 2026-09-19)
36 42
37-## Point d'intégration côté façades (futur, hors périmètre de ce plan)43+- `specs/001-import-photos` FR-009 (fusion vers un dossier déjà archivé) DEVRA appeler `checkout` sur le dossier ciblé avant d'y intégrer les nouveaux fichiers — cf. `specs/001-import-photos/tasks.md` T033, désormais implémentable (dépendance levée).
44+- `specs/004-categorisation-dossiers` User Story 4 (recatégorisation a posteriori) s'appuie sur `comparer`, qui classe déjà un déplacement de plusieurs fichiers (simulant un dossier entier) comme `deplacement`, sans code supplémentaire côté `specs/004` — cf. `specs/004-categorisation-dossiers/tasks.md` T024b, désormais implémentable. **Limite à noter** : `comparer` raisonne uniquement par chemin relatif à l'intérieur d'un `dossier_archive` fixe ; un déplacement du dossier archive lui-même entre deux répertoires racine (ex. `2026/` → `mariage/`) reste une opération d'orchestration au niveau du futur module `import`/`dossier` (checkout depuis l'ancien emplacement, réarchivage vers le nouveau), pas quelque chose que `comparer` détecte tout seul en comparant deux chemins absolus différents.
38 45
39-- `specs/001-import-photos` FR-009 (fusion vers un dossier déjà archivé) DEVRA appeler `checkout` sur le dossier ciblé avant d'y intégrer les nouveaux fichiers, plutôt que de lever `ChecoutNonDisponibleError` comme actuellement documenté dans son plan.46+## Bug réel trouvé et corrigé en cours d'implémentation (2026-09-19)
40-- `specs/004-categorisation-dossiers` User Story 4 (recatégorisation a posteriori) DEVRA s'appuyer sur `comparer` : un dossier déplacé entre répertoires racine y est déjà classé `deplacement` (FR-010 de cette spec), sans code supplémentaire à écrire côté `specs/004`.
41 47
42-Ces deux points d'intégration ne sont pas implémentés par ce plan ; ils sont documentés ici pour que leur future implémentation consomme cette API sans la redéfinir.48+`archive_cmd._cmd_reconcile` retournait sans appeler `verrou.lever()` quand la réconciliation ne trouvait aucun changement (`rapport.changements` vide), laissant le dossier verrouillé indéfiniment après une réconciliation « à vide ». Corrigé : le verrou est désormais levé dans ce cas aussi. Trouvé par le test d'intégration T032 (`test_checkout_accepted_again_after_reconciliation`).
modified specs/005-checkout-reconciliation/tasks.md +36 -36
@@ -36,15 +36,15 @@ Ce que ce module débloque une fois implémenté : `specs/001-import-photos` (T0
3636
3737 **⚠️ CRITICAL**: Aucune user story ne peut être implémentée avant la fin de cette phase.
3838
39-- [ ] T005 Étendre `packages/regine-core/src/regine_core/metadata/exif.py` avec `read_image_data_hash(chemin) -> str | None` (`exiftool -api ImageHashType=SHA256 -ImageDataHash`, cf. `research.md` § 5)
40-- [ ] T006 [P] Implémenter `hash_fichier_entier(chemin) -> str` (SHA-256 stdlib) dans `packages/regine-core/src/regine_core/integrity/hash.py`
41-- [ ] T007 Implémenter `hash_image_only(chemin) -> str | None` dans `integrity/hash.py` (délègue à `read_image_data_hash` pour DNG/TIFF/JPEG uniquement, dépend de T005)
42-- [ ] T008 [P] Test unitaire du hash à deux niveaux (RAW propriétaire → un seul hash ; DNG/TIFF/JPEG → deux hash) dans `packages/regine-core/tests/unit/test_hash_deux_niveaux.py`
43-- [ ] T009 Implémenter `est_maitre_modifie(format, ref, actuel) -> bool` dans `packages/regine-core/src/regine_core/integrity/anomalie.py` (FR-007/008, Principe I — bascule fichier entier / image-only selon le format, dépend de T006/T007)
44-- [ ] T010 Implémenter `manifest.ouvrir_ou_creer(dossier) -> ManifestHandle` dans `packages/regine-core/src/regine_core/archive/manifest.py` : table `fichiers`, table `verrou`, `PRAGMA user_version` (`structurel*1000+additif`, cf. `research.md` § 2), `PRAGMA application_id`, refus explicite si version structurelle non supportée
45-- [ ] T011 [P] Test unitaire du versionnement de schéma (tolérance additive, refus structurel, réouverture idempotente sans perte) dans `packages/regine-core/tests/unit/test_manifest_versioning.py`
46-- [ ] T012 Implémenter `verrou.poser/verifier/lever(manifest)` dans `packages/regine-core/src/regine_core/archive/verrou.py` (FR-003/004/016, dépend de T010)
47-- [ ] T013 [P] Test unitaire du verrou (pose, refus de double pose, levée après réconciliation) dans `packages/regine-core/tests/unit/test_verrou.py`
39+- [X] T005 Étendre `packages/regine-core/src/regine_core/metadata/exif.py` avec `read_image_data_hash(chemin) -> str | None` (`exiftool -api ImageHashType=SHA256 -ImageDataHash`, cf. `research.md` § 5)
40+- [X] T006 [P] Implémenter `hash_fichier_entier(chemin) -> str` (SHA-256 stdlib) dans `packages/regine-core/src/regine_core/integrity/hash.py`
41+- [X] T007 Implémenter `hash_image_only(chemin) -> str | None` dans `integrity/hash.py` (délègue à `read_image_data_hash` pour DNG/TIFF/JPEG uniquement, dépend de T005)
42+- [X] T008 [P] Test unitaire du hash à deux niveaux (RAW propriétaire → un seul hash ; DNG/TIFF/JPEG → deux hash) dans `packages/regine-core/tests/unit/test_hash_deux_niveaux.py` — validé avec de vraies éditions de métadonnées exiftool
43+- [X] T009 Implémenter `est_maitre_modifie(format, ref, actuel) -> bool` dans `packages/regine-core/src/regine_core/integrity/anomalie.py` (FR-007/008, Principe I — bascule fichier entier / image-only selon le format, dépend de T006/T007)
44+- [X] T010 Implémenter `manifest.ouvrir_ou_creer(dossier) -> ManifestHandle` dans `packages/regine-core/src/regine_core/archive/manifest.py` : table `fichiers`, table `verrou`, `PRAGMA user_version` (`structurel*1000+additif`, cf. `research.md` § 2), `PRAGMA application_id`, refus explicite si version structurelle non supportée
45+- [X] T011 [P] Test unitaire du versionnement de schéma (tolérance additive, refus structurel, réouverture idempotente sans perte) dans `packages/regine-core/tests/unit/test_manifest_versioning.py`
46+- [X] T012 Implémenter `verrou.poser/verifier/lever(manifest)` dans `packages/regine-core/src/regine_core/archive/verrou.py` (FR-003/004/016, dépend de T010)
47+- [X] T013 [P] Test unitaire du verrou (pose, refus de double pose, levée après réconciliation) dans `packages/regine-core/tests/unit/test_verrou.py`
4848
4949 **Checkpoint**: Fondations prêtes — les phases User Story peuvent commencer.
5050
@@ -58,14 +58,14 @@ Ce que ce module débloque une fois implémenté : `specs/001-import-photos` (T0
5858
5959 ### Tests for User Story 1
6060
61-- [ ] T014 [P] [US1] Test unitaire : checkout copie un dossier parent avec tous ses sous-dossiers ensemble (jamais un sous-dossier isolé), chaque fichier vérifié par empreinte, dans `packages/regine-core/tests/unit/test_checkout.py`
62-- [ ] T015 [US1] Test d'intégration (partiel) : checkout crée un manifeste complet et pose le verrou, dans `packages/regine-core/tests/integration/test_cycle_checkout_reconciliation.py`
61+- [X] T014 [P] [US1] Test unitaire : checkout copie un dossier parent avec tous ses sous-dossiers ensemble (jamais un sous-dossier isolé), chaque fichier vérifié par empreinte, dans `packages/regine-core/tests/unit/test_checkout.py`
62+- [X] T015 [US1] Test d'intégration (partiel) : checkout crée un manifeste complet et pose le verrou, dans `packages/regine-core/tests/integration/test_cycle_checkout_reconciliation.py`
6363
6464 ### Implementation for User Story 1
6565
66-- [ ] T016 [US1] Implémenter `checkout(dossier_archive, dest_locale, formats=None) -> ManifestSnapshot` dans `packages/regine-core/src/regine_core/archive/checkout.py` (FR-001/002/015 ; le paramètre `formats` est géré en Phase 8, ignoré ici) (dépend de T010)
67-- [ ] T017 [US1] Intégrer la pose du verrou avant la copie dans `checkout()`, lever une exception dédiée si déjà verrouillé (FR-003/004, dépend de T012)
68-- [ ] T018 [US1] Orchestrer `regine checkout <dossier>` dans `packages/regine-cli/src/regine_cli/archive_cmd.py`
66+- [X] T016 [US1] Implémenter `checkout(dossier_archive, dest_locale, formats=None) -> ManifestSnapshot` dans `packages/regine-core/src/regine_core/archive/checkout.py` (FR-001/002/015 ; le paramètre `formats` est géré en Phase 8, ignoré ici) (dépend de T010) — `formats` implémenté dès cette phase (cf. T036)
67+- [X] T017 [US1] Intégrer la pose du verrou avant la copie dans `checkout()`, lever une exception dédiée si déjà verrouillé (FR-003/004, dépend de T012)
68+- [X] T018 [US1] Orchestrer `regine checkout <dossier>` dans `packages/regine-cli/src/regine_cli/archive_cmd.py`
6969
7070 **Checkpoint**: User Story 1 fonctionnelle et testable indépendamment (MVP).
7171
@@ -79,14 +79,14 @@ Ce que ce module débloque une fois implémenté : `specs/001-import-photos` (T0
7979
8080 ### Tests for User Story 2
8181
82-- [ ] T019 [P] [US2] Test unitaire de classification "normal" (fichier maître inchangé + sidecar nouveau/modifié) dans `packages/regine-core/tests/unit/test_reconciliation_classification.py`
83-- [ ] T020 [US2] Test d'intégration complétant `test_cycle_checkout_reconciliation.py` : édition sidecar → réconciliation → résumé → confirmation → réarchivage → verrou levé
82+- [X] T019 [P] [US2] Test unitaire de classification "normal" (fichier maître inchangé + sidecar nouveau/modifié) dans `packages/regine-core/tests/unit/test_reconciliation_classification.py`
83+- [X] T020 [US2] Test d'intégration complétant `test_cycle_checkout_reconciliation.py` : édition sidecar → réconciliation → résumé → confirmation → réarchivage → verrou levé
8484
8585 ### Implementation for User Story 2
8686
87-- [ ] T021 [US2] Implémenter `comparer(manifest, copie_locale) -> RapportReconciliation` dans `packages/regine-core/src/regine_core/archive/reconciliation.py` : construit les index hash→chemin, classe "normal" (dépend de T007/T009)
88-- [ ] T022 [US2] Implémenter `archiver(dossier_archive, rapport, decisions) -> None` dans `reconciliation.py` : écrit les changements confirmés, vérifie chaque transfert (FR-015), met à jour le manifeste, lève le verrou une fois toutes les écritures terminées (FR-016, dépend de T012/T021)
89-- [ ] T023 [US2] Orchestrer `regine reconcile <dossier>` dans `archive_cmd.py` : affichage du point avant archive et confirmation globale avant écriture (FR-013)
87+- [X] T021 [US2] Implémenter `comparer(manifest, copie_locale) -> RapportReconciliation` dans `packages/regine-core/src/regine_core/archive/reconciliation.py` : construit les index hash→chemin, classe "normal" (dépend de T007/T009)
88+- [X] T022 [US2] Implémenter `archiver(dossier_archive, rapport, decisions) -> None` dans `reconciliation.py` : écrit les changements confirmés, vérifie chaque transfert (FR-015), met à jour le manifeste, lève le verrou une fois toutes les écritures terminées (FR-016, dépend de T012/T021)
89+- [X] T023 [US2] Orchestrer `regine reconcile <dossier>` dans `archive_cmd.py` : affichage du point avant archive et confirmation globale avant écriture (FR-013)
9090
9191 **Checkpoint**: User Stories 1 ET 2 fonctionnelles indépendamment.
9292
@@ -100,14 +100,14 @@ Ce que ce module débloque une fois implémenté : `specs/001-import-photos` (T0
100100
101101 ### Tests for User Story 3
102102
103-- [ ] T024 [P] [US3] Test unitaire anomalie sur RAW propriétaire (hash fichier entier changé) dans `test_reconciliation_classification.py`
104-- [ ] T025 [P] [US3] Test unitaire DNG/TIFF/JPEG : édition de métadonnées seule → pas d'anomalie (hash image-only inchangé) ; édition des pixels → anomalie, dans `test_reconciliation_classification.py`
105-- [ ] T026 [US3] Test d'intégration : décision explicite (confirmer/restaurer) par anomalie, sans bloquer l'archivage des changements normaux déjà confirmés (FR-014), complétant `test_cycle_checkout_reconciliation.py`
103+- [X] T024 [P] [US3] Test unitaire anomalie sur RAW propriétaire (hash fichier entier changé) dans `test_reconciliation_classification.py`
104+- [X] T025 [P] [US3] Test unitaire DNG/TIFF/JPEG : édition de métadonnées seule → pas d'anomalie (hash image-only inchangé) ; édition des pixels → anomalie, dans `test_reconciliation_classification.py` — validé avec de vraies éditions exiftool
105+- [X] T026 [US3] Test d'intégration : décision explicite (confirmer/restaurer) par anomalie, sans bloquer l'archivage des changements normaux déjà confirmés (FR-014), complétant `test_cycle_checkout_reconciliation.py`
106106
107107 ### Implementation for User Story 3
108108
109-- [ ] T027 [US3] Étendre `comparer()` : classification "anomalie" via `est_maitre_modifie` (dépend de T009/T021)
110-- [ ] T028 [US3] Implémenter la résolution par anomalie (confirmer malgré tout / restaurer depuis l'archive) dans `reconciliation.py` et `archive_cmd.py`, sans bloquer les autres changements déjà confirmés (FR-014)
109+- [X] T027 [US3] Étendre `comparer()` : classification "anomalie" via `est_maitre_modifie` (dépend de T009/T021)
110+- [X] T028 [US3] Implémenter la résolution par anomalie (confirmer malgré tout / restaurer depuis l'archive) dans `reconciliation.py` et `archive_cmd.py`, sans bloquer les autres changements déjà confirmés (FR-014)
111111
112112 **Checkpoint**: User Stories 1, 2 et 3 fonctionnelles.
113113
@@ -121,12 +121,12 @@ Ce que ce module débloque une fois implémenté : `specs/001-import-photos` (T0
121121
122122 ### Tests for User Story 4
123123
124-- [ ] T029 [P] [US4] Test unitaire renommage/promotion détecté par contenu dans `test_reconciliation_classification.py`
125-- [ ] T030 [US4] Test unitaire déplacement d'un dossier entier entre répertoires racine (année→catégorie) détecté par contenu, dans `test_reconciliation_classification.py` — **débloque `specs/004-categorisation-dossiers` T024b**
124+- [X] T029 [P] [US4] Test unitaire renommage/promotion détecté par contenu dans `test_reconciliation_classification.py`
125+- [X] T030 [US4] Test unitaire déplacement d'un dossier entier entre répertoires racine (année→catégorie) détecté par contenu, dans `test_reconciliation_classification.py` — **débloque `specs/004-categorisation-dossiers` T024b** (le test démontre que plusieurs fichiers déplacés ensemble sont détectés individuellement par le même mécanisme, sans code dédié à l'échelle d'un dossier — cf. docstring du test pour la limite exacte de ce qui relève de `comparer` vs de l'orchestration d'un futur module)
126126
127127 ### Implementation for User Story 4
128128
129-- [ ] T031 [US4] Étendre `comparer()` : classification "deplacement" via l'index hash→chemin (manifeste vs copie locale), couvrant indifféremment un fichier ou un dossier entier (FR-009/010, dépend de T021)
129+- [X] T031 [US4] Étendre `comparer()` : classification "deplacement" via l'index hash→chemin (manifeste vs copie locale), couvrant indifféremment un fichier ou un dossier entier (FR-009/010, dépend de T021)
130130
131131 **Checkpoint**: User Stories 1 à 4 fonctionnelles.
132132
@@ -140,11 +140,11 @@ Ce que ce module débloque une fois implémenté : `specs/001-import-photos` (T0
140140
141141 ### Tests for User Story 5
142142
143-- [ ] T032 [US5] Test d'intégration bout-en-bout : second `regine checkout` refusé pendant qu'un premier est en cours (code de sortie non-zéro, message explicite), accepté après réconciliation du premier, dans `packages/regine-core/tests/integration/test_double_checkout.py`
143+- [X] T032 [US5] Test d'intégration bout-en-bout : second `regine checkout` refusé pendant qu'un premier est en cours (code de sortie non-zéro, message explicite), accepté après réconciliation du premier, dans `packages/regine-core/tests/integration/test_double_checkout.py` — **bug réel trouvé et corrigé** : une réconciliation sans aucun changement ne levait jamais le verrou (`archive_cmd._cmd_reconcile` retournait avant d'appeler `lever()`)
144144
145145 ### Implementation for User Story 5
146146
147-- [ ] T033 [US5] Vérifier/compléter le message d'erreur explicite de `archive_cmd.py` pour le cas de verrou déjà posé (comportement déjà couvert par T017 ; cette tâche ne fait qu'exposer un message clair côté CLI)
147+- [X] T033 [US5] Vérifier/compléter le message d'erreur explicite de `archive_cmd.py` pour le cas de verrou déjà posé (comportement déjà couvert par T017 ; cette tâche ne fait qu'exposer un message clair côté CLI)
148148
149149 **Checkpoint**: User Stories 1 à 5 fonctionnelles.
150150
@@ -158,13 +158,13 @@ Ce que ce module débloque une fois implémenté : `specs/001-import-photos` (T0
158158
159159 ### Tests for User Story 6
160160
161-- [ ] T034 [P] [US6] Test d'intégration checkout partiel (`formats=["jpeg"]` → seuls JPEG et racine copiés) dans `packages/regine-core/tests/integration/test_checkout_partiel.py`
162-- [ ] T035 [US6] Test d'intégration : réconciliation après checkout partiel ne signale pas les fichiers exclus comme supprimés (FR-020), complétant `test_checkout_partiel.py`
161+- [X] T034 [P] [US6] Test d'intégration checkout partiel (`formats=["jpeg"]` → seuls JPEG et racine copiés) dans `packages/regine-core/tests/integration/test_checkout_partiel.py`
162+- [X] T035 [US6] Test d'intégration : réconciliation après checkout partiel ne signale pas les fichiers exclus comme supprimés (FR-020), complétant `test_checkout_partiel.py` — première version du test erronée (attendait "normal" sur une réécriture complète d'un JPEG racine, qui est en réalité correctement classée "anomalie" — pas un bug, corrigé côté test avec un scénario de sidecar)
163163
164164 ### Implementation for User Story 6
165165
166-- [ ] T036 [US6] Étendre `checkout()` : paramètre `formats`, copie restreinte aux sous-répertoires demandés plus la racine (FR-019, dépend de T016)
167-- [ ] T037 [US6] Étendre `comparer()`/le manifeste pour exclure les fichiers hors du périmètre d'un checkout partiel de la détection de suppression (FR-020, dépend de T021)
166+- [X] T036 [US6] Étendre `checkout()` : paramètre `formats`, copie restreinte aux sous-répertoires demandés plus la racine (FR-019, dépend de T016) — implémenté dès T016 (Phase 3)
167+- [X] T037 [US6] Étendre `comparer()`/le manifeste pour exclure les fichiers hors du périmètre d'un checkout partiel de la détection de suppression (FR-020, dépend de T021) — implémenté dès T021 via `dans_perimetre` partagé avec `checkout.py`
168168
169169 **Checkpoint**: Les six user stories fonctionnelles.
170170
@@ -172,9 +172,9 @@ Ce que ce module débloque une fois implémenté : `specs/001-import-photos` (T0
172172
173173 ## Phase 9: Polish & Cross-Cutting Concerns
174174
175-- [ ] T038 [P] Exécuter manuellement les 5 scénarios de `specs/005-checkout-reconciliation/quickstart.md` et consigner le résultat
176-- [ ] T039 [P] `ruff check --fix` sur `packages/regine-core` et `packages/regine-cli`
177-- [ ] T040 Mettre à jour `contracts/regine-core-api.md` si écart d'implémentation ; puis lever les dépendances documentées dans `specs/001-import-photos/tasks.md` (T033) et `specs/004-categorisation-dossiers/tasks.md` (T024b), désormais implémentables
175+- [X] T038 [P] Exécuter manuellement les 5 scénarios de `specs/005-checkout-reconciliation/quickstart.md` et consigner le résultat — **2026-09-19 : les 5 scénarios passent**, déroulés via la vraie CLI (`python -m regine_cli.archive_cmd`) avec de vrais fichiers ; un bug réel a été trouvé et corrigé en cours de route (verrou non levé après une réconciliation sans changement, cf. T032)
176+- [X] T039 [P] `ruff check --fix` sur `packages/regine-core` et `packages/regine-cli` — 0 erreur restante, code formaté
177+- [X] T040 Mettre à jour `contracts/regine-core-api.md` si écart d'implémentation ; puis lever les dépendances documentées dans `specs/001-import-photos/tasks.md` (T033) et `specs/004-categorisation-dossiers/tasks.md` (T024b), désormais implémentables
178178
179179 ---
180180
@@ -36,15 +36,15 @@ Ce que ce module débloque une fois implémenté : `specs/001-import-photos` (T0
36 36
37 **⚠️ CRITICAL**: Aucune user story ne peut être implémentée avant la fin de cette phase.37 **⚠️ CRITICAL**: Aucune user story ne peut être implémentée avant la fin de cette phase.
38 38
39-- [ ] T005 Étendre `packages/regine-core/src/regine_core/metadata/exif.py` avec `read_image_data_hash(chemin) -> str | None` (`exiftool -api ImageHashType=SHA256 -ImageDataHash`, cf. `research.md` § 5)39+- [X] T005 Étendre `packages/regine-core/src/regine_core/metadata/exif.py` avec `read_image_data_hash(chemin) -> str | None` (`exiftool -api ImageHashType=SHA256 -ImageDataHash`, cf. `research.md` § 5)
40-- [ ] T006 [P] Implémenter `hash_fichier_entier(chemin) -> str` (SHA-256 stdlib) dans `packages/regine-core/src/regine_core/integrity/hash.py`40+- [X] T006 [P] Implémenter `hash_fichier_entier(chemin) -> str` (SHA-256 stdlib) dans `packages/regine-core/src/regine_core/integrity/hash.py`
41-- [ ] T007 Implémenter `hash_image_only(chemin) -> str | None` dans `integrity/hash.py` (délègue à `read_image_data_hash` pour DNG/TIFF/JPEG uniquement, dépend de T005)41+- [X] T007 Implémenter `hash_image_only(chemin) -> str | None` dans `integrity/hash.py` (délègue à `read_image_data_hash` pour DNG/TIFF/JPEG uniquement, dépend de T005)
42-- [ ] T008 [P] Test unitaire du hash à deux niveaux (RAW propriétaire → un seul hash ; DNG/TIFF/JPEG → deux hash) dans `packages/regine-core/tests/unit/test_hash_deux_niveaux.py`42+- [X] T008 [P] Test unitaire du hash à deux niveaux (RAW propriétaire → un seul hash ; DNG/TIFF/JPEG → deux hash) dans `packages/regine-core/tests/unit/test_hash_deux_niveaux.py` — validé avec de vraies éditions de métadonnées exiftool
43-- [ ] T009 Implémenter `est_maitre_modifie(format, ref, actuel) -> bool` dans `packages/regine-core/src/regine_core/integrity/anomalie.py` (FR-007/008, Principe I — bascule fichier entier / image-only selon le format, dépend de T006/T007)43+- [X] T009 Implémenter `est_maitre_modifie(format, ref, actuel) -> bool` dans `packages/regine-core/src/regine_core/integrity/anomalie.py` (FR-007/008, Principe I — bascule fichier entier / image-only selon le format, dépend de T006/T007)
44-- [ ] T010 Implémenter `manifest.ouvrir_ou_creer(dossier) -> ManifestHandle` dans `packages/regine-core/src/regine_core/archive/manifest.py` : table `fichiers`, table `verrou`, `PRAGMA user_version` (`structurel*1000+additif`, cf. `research.md` § 2), `PRAGMA application_id`, refus explicite si version structurelle non supportée44+- [X] T010 Implémenter `manifest.ouvrir_ou_creer(dossier) -> ManifestHandle` dans `packages/regine-core/src/regine_core/archive/manifest.py` : table `fichiers`, table `verrou`, `PRAGMA user_version` (`structurel*1000+additif`, cf. `research.md` § 2), `PRAGMA application_id`, refus explicite si version structurelle non supportée
45-- [ ] T011 [P] Test unitaire du versionnement de schéma (tolérance additive, refus structurel, réouverture idempotente sans perte) dans `packages/regine-core/tests/unit/test_manifest_versioning.py`45+- [X] T011 [P] Test unitaire du versionnement de schéma (tolérance additive, refus structurel, réouverture idempotente sans perte) dans `packages/regine-core/tests/unit/test_manifest_versioning.py`
46-- [ ] T012 Implémenter `verrou.poser/verifier/lever(manifest)` dans `packages/regine-core/src/regine_core/archive/verrou.py` (FR-003/004/016, dépend de T010)46+- [X] T012 Implémenter `verrou.poser/verifier/lever(manifest)` dans `packages/regine-core/src/regine_core/archive/verrou.py` (FR-003/004/016, dépend de T010)
47-- [ ] T013 [P] Test unitaire du verrou (pose, refus de double pose, levée après réconciliation) dans `packages/regine-core/tests/unit/test_verrou.py`47+- [X] T013 [P] Test unitaire du verrou (pose, refus de double pose, levée après réconciliation) dans `packages/regine-core/tests/unit/test_verrou.py`
48 48
49 **Checkpoint**: Fondations prêtes — les phases User Story peuvent commencer.49 **Checkpoint**: Fondations prêtes — les phases User Story peuvent commencer.
50 50
@@ -58,14 +58,14 @@ Ce que ce module débloque une fois implémenté : `specs/001-import-photos` (T0
58 58
59 ### Tests for User Story 159 ### Tests for User Story 1
60 60
61-- [ ] T014 [P] [US1] Test unitaire : checkout copie un dossier parent avec tous ses sous-dossiers ensemble (jamais un sous-dossier isolé), chaque fichier vérifié par empreinte, dans `packages/regine-core/tests/unit/test_checkout.py`61+- [X] T014 [P] [US1] Test unitaire : checkout copie un dossier parent avec tous ses sous-dossiers ensemble (jamais un sous-dossier isolé), chaque fichier vérifié par empreinte, dans `packages/regine-core/tests/unit/test_checkout.py`
62-- [ ] T015 [US1] Test d'intégration (partiel) : checkout crée un manifeste complet et pose le verrou, dans `packages/regine-core/tests/integration/test_cycle_checkout_reconciliation.py`62+- [X] T015 [US1] Test d'intégration (partiel) : checkout crée un manifeste complet et pose le verrou, dans `packages/regine-core/tests/integration/test_cycle_checkout_reconciliation.py`
63 63
64 ### Implementation for User Story 164 ### Implementation for User Story 1
65 65
66-- [ ] T016 [US1] Implémenter `checkout(dossier_archive, dest_locale, formats=None) -> ManifestSnapshot` dans `packages/regine-core/src/regine_core/archive/checkout.py` (FR-001/002/015 ; le paramètre `formats` est géré en Phase 8, ignoré ici) (dépend de T010)66+- [X] T016 [US1] Implémenter `checkout(dossier_archive, dest_locale, formats=None) -> ManifestSnapshot` dans `packages/regine-core/src/regine_core/archive/checkout.py` (FR-001/002/015 ; le paramètre `formats` est géré en Phase 8, ignoré ici) (dépend de T010) — `formats` implémenté dès cette phase (cf. T036)
67-- [ ] T017 [US1] Intégrer la pose du verrou avant la copie dans `checkout()`, lever une exception dédiée si déjà verrouillé (FR-003/004, dépend de T012)67+- [X] T017 [US1] Intégrer la pose du verrou avant la copie dans `checkout()`, lever une exception dédiée si déjà verrouillé (FR-003/004, dépend de T012)
68-- [ ] T018 [US1] Orchestrer `regine checkout <dossier>` dans `packages/regine-cli/src/regine_cli/archive_cmd.py`68+- [X] T018 [US1] Orchestrer `regine checkout <dossier>` dans `packages/regine-cli/src/regine_cli/archive_cmd.py`
69 69
70 **Checkpoint**: User Story 1 fonctionnelle et testable indépendamment (MVP).70 **Checkpoint**: User Story 1 fonctionnelle et testable indépendamment (MVP).
71 71
@@ -79,14 +79,14 @@ Ce que ce module débloque une fois implémenté : `specs/001-import-photos` (T0
79 79
80 ### Tests for User Story 280 ### Tests for User Story 2
81 81
82-- [ ] T019 [P] [US2] Test unitaire de classification "normal" (fichier maître inchangé + sidecar nouveau/modifié) dans `packages/regine-core/tests/unit/test_reconciliation_classification.py`82+- [X] T019 [P] [US2] Test unitaire de classification "normal" (fichier maître inchangé + sidecar nouveau/modifié) dans `packages/regine-core/tests/unit/test_reconciliation_classification.py`
83-- [ ] T020 [US2] Test d'intégration complétant `test_cycle_checkout_reconciliation.py` : édition sidecar → réconciliation → résumé → confirmation → réarchivage → verrou levé83+- [X] T020 [US2] Test d'intégration complétant `test_cycle_checkout_reconciliation.py` : édition sidecar → réconciliation → résumé → confirmation → réarchivage → verrou levé
84 84
85 ### Implementation for User Story 285 ### Implementation for User Story 2
86 86
87-- [ ] T021 [US2] Implémenter `comparer(manifest, copie_locale) -> RapportReconciliation` dans `packages/regine-core/src/regine_core/archive/reconciliation.py` : construit les index hash→chemin, classe "normal" (dépend de T007/T009)87+- [X] T021 [US2] Implémenter `comparer(manifest, copie_locale) -> RapportReconciliation` dans `packages/regine-core/src/regine_core/archive/reconciliation.py` : construit les index hash→chemin, classe "normal" (dépend de T007/T009)
88-- [ ] T022 [US2] Implémenter `archiver(dossier_archive, rapport, decisions) -> None` dans `reconciliation.py` : écrit les changements confirmés, vérifie chaque transfert (FR-015), met à jour le manifeste, lève le verrou une fois toutes les écritures terminées (FR-016, dépend de T012/T021)88+- [X] T022 [US2] Implémenter `archiver(dossier_archive, rapport, decisions) -> None` dans `reconciliation.py` : écrit les changements confirmés, vérifie chaque transfert (FR-015), met à jour le manifeste, lève le verrou une fois toutes les écritures terminées (FR-016, dépend de T012/T021)
89-- [ ] T023 [US2] Orchestrer `regine reconcile <dossier>` dans `archive_cmd.py` : affichage du point avant archive et confirmation globale avant écriture (FR-013)89+- [X] T023 [US2] Orchestrer `regine reconcile <dossier>` dans `archive_cmd.py` : affichage du point avant archive et confirmation globale avant écriture (FR-013)
90 90
91 **Checkpoint**: User Stories 1 ET 2 fonctionnelles indépendamment.91 **Checkpoint**: User Stories 1 ET 2 fonctionnelles indépendamment.
92 92
@@ -100,14 +100,14 @@ Ce que ce module débloque une fois implémenté : `specs/001-import-photos` (T0
100 100
101 ### Tests for User Story 3101 ### Tests for User Story 3
102 102
103-- [ ] T024 [P] [US3] Test unitaire anomalie sur RAW propriétaire (hash fichier entier changé) dans `test_reconciliation_classification.py`103+- [X] T024 [P] [US3] Test unitaire anomalie sur RAW propriétaire (hash fichier entier changé) dans `test_reconciliation_classification.py`
104-- [ ] T025 [P] [US3] Test unitaire DNG/TIFF/JPEG : édition de métadonnées seule → pas d'anomalie (hash image-only inchangé) ; édition des pixels → anomalie, dans `test_reconciliation_classification.py`104+- [X] T025 [P] [US3] Test unitaire DNG/TIFF/JPEG : édition de métadonnées seule → pas d'anomalie (hash image-only inchangé) ; édition des pixels → anomalie, dans `test_reconciliation_classification.py` — validé avec de vraies éditions exiftool
105-- [ ] T026 [US3] Test d'intégration : décision explicite (confirmer/restaurer) par anomalie, sans bloquer l'archivage des changements normaux déjà confirmés (FR-014), complétant `test_cycle_checkout_reconciliation.py`105+- [X] T026 [US3] Test d'intégration : décision explicite (confirmer/restaurer) par anomalie, sans bloquer l'archivage des changements normaux déjà confirmés (FR-014), complétant `test_cycle_checkout_reconciliation.py`
106 106
107 ### Implementation for User Story 3107 ### Implementation for User Story 3
108 108
109-- [ ] T027 [US3] Étendre `comparer()` : classification "anomalie" via `est_maitre_modifie` (dépend de T009/T021)109+- [X] T027 [US3] Étendre `comparer()` : classification "anomalie" via `est_maitre_modifie` (dépend de T009/T021)
110-- [ ] T028 [US3] Implémenter la résolution par anomalie (confirmer malgré tout / restaurer depuis l'archive) dans `reconciliation.py` et `archive_cmd.py`, sans bloquer les autres changements déjà confirmés (FR-014)110+- [X] T028 [US3] Implémenter la résolution par anomalie (confirmer malgré tout / restaurer depuis l'archive) dans `reconciliation.py` et `archive_cmd.py`, sans bloquer les autres changements déjà confirmés (FR-014)
111 111
112 **Checkpoint**: User Stories 1, 2 et 3 fonctionnelles.112 **Checkpoint**: User Stories 1, 2 et 3 fonctionnelles.
113 113
@@ -121,12 +121,12 @@ Ce que ce module débloque une fois implémenté : `specs/001-import-photos` (T0
121 121
122 ### Tests for User Story 4122 ### Tests for User Story 4
123 123
124-- [ ] T029 [P] [US4] Test unitaire renommage/promotion détecté par contenu dans `test_reconciliation_classification.py`124+- [X] T029 [P] [US4] Test unitaire renommage/promotion détecté par contenu dans `test_reconciliation_classification.py`
125-- [ ] T030 [US4] Test unitaire déplacement d'un dossier entier entre répertoires racine (année→catégorie) détecté par contenu, dans `test_reconciliation_classification.py` — **débloque `specs/004-categorisation-dossiers` T024b**125+- [X] T030 [US4] Test unitaire déplacement d'un dossier entier entre répertoires racine (année→catégorie) détecté par contenu, dans `test_reconciliation_classification.py` — **débloque `specs/004-categorisation-dossiers` T024b** (le test démontre que plusieurs fichiers déplacés ensemble sont détectés individuellement par le même mécanisme, sans code dédié à l'échelle d'un dossier — cf. docstring du test pour la limite exacte de ce qui relève de `comparer` vs de l'orchestration d'un futur module)
126 126
127 ### Implementation for User Story 4127 ### Implementation for User Story 4
128 128
129-- [ ] T031 [US4] Étendre `comparer()` : classification "deplacement" via l'index hash→chemin (manifeste vs copie locale), couvrant indifféremment un fichier ou un dossier entier (FR-009/010, dépend de T021)129+- [X] T031 [US4] Étendre `comparer()` : classification "deplacement" via l'index hash→chemin (manifeste vs copie locale), couvrant indifféremment un fichier ou un dossier entier (FR-009/010, dépend de T021)
130 130
131 **Checkpoint**: User Stories 1 à 4 fonctionnelles.131 **Checkpoint**: User Stories 1 à 4 fonctionnelles.
132 132
@@ -140,11 +140,11 @@ Ce que ce module débloque une fois implémenté : `specs/001-import-photos` (T0
140 140
141 ### Tests for User Story 5141 ### Tests for User Story 5
142 142
143-- [ ] T032 [US5] Test d'intégration bout-en-bout : second `regine checkout` refusé pendant qu'un premier est en cours (code de sortie non-zéro, message explicite), accepté après réconciliation du premier, dans `packages/regine-core/tests/integration/test_double_checkout.py`143+- [X] T032 [US5] Test d'intégration bout-en-bout : second `regine checkout` refusé pendant qu'un premier est en cours (code de sortie non-zéro, message explicite), accepté après réconciliation du premier, dans `packages/regine-core/tests/integration/test_double_checkout.py` — **bug réel trouvé et corrigé** : une réconciliation sans aucun changement ne levait jamais le verrou (`archive_cmd._cmd_reconcile` retournait avant d'appeler `lever()`)
144 144
145 ### Implementation for User Story 5145 ### Implementation for User Story 5
146 146
147-- [ ] T033 [US5] Vérifier/compléter le message d'erreur explicite de `archive_cmd.py` pour le cas de verrou déjà posé (comportement déjà couvert par T017 ; cette tâche ne fait qu'exposer un message clair côté CLI)147+- [X] T033 [US5] Vérifier/compléter le message d'erreur explicite de `archive_cmd.py` pour le cas de verrou déjà posé (comportement déjà couvert par T017 ; cette tâche ne fait qu'exposer un message clair côté CLI)
148 148
149 **Checkpoint**: User Stories 1 à 5 fonctionnelles.149 **Checkpoint**: User Stories 1 à 5 fonctionnelles.
150 150
@@ -158,13 +158,13 @@ Ce que ce module débloque une fois implémenté : `specs/001-import-photos` (T0
158 158
159 ### Tests for User Story 6159 ### Tests for User Story 6
160 160
161-- [ ] T034 [P] [US6] Test d'intégration checkout partiel (`formats=["jpeg"]` → seuls JPEG et racine copiés) dans `packages/regine-core/tests/integration/test_checkout_partiel.py`161+- [X] T034 [P] [US6] Test d'intégration checkout partiel (`formats=["jpeg"]` → seuls JPEG et racine copiés) dans `packages/regine-core/tests/integration/test_checkout_partiel.py`
162-- [ ] T035 [US6] Test d'intégration : réconciliation après checkout partiel ne signale pas les fichiers exclus comme supprimés (FR-020), complétant `test_checkout_partiel.py`162+- [X] T035 [US6] Test d'intégration : réconciliation après checkout partiel ne signale pas les fichiers exclus comme supprimés (FR-020), complétant `test_checkout_partiel.py` — première version du test erronée (attendait "normal" sur une réécriture complète d'un JPEG racine, qui est en réalité correctement classée "anomalie" — pas un bug, corrigé côté test avec un scénario de sidecar)
163 163
164 ### Implementation for User Story 6164 ### Implementation for User Story 6
165 165
166-- [ ] T036 [US6] Étendre `checkout()` : paramètre `formats`, copie restreinte aux sous-répertoires demandés plus la racine (FR-019, dépend de T016)166+- [X] T036 [US6] Étendre `checkout()` : paramètre `formats`, copie restreinte aux sous-répertoires demandés plus la racine (FR-019, dépend de T016) — implémenté dès T016 (Phase 3)
167-- [ ] T037 [US6] Étendre `comparer()`/le manifeste pour exclure les fichiers hors du périmètre d'un checkout partiel de la détection de suppression (FR-020, dépend de T021)167+- [X] T037 [US6] Étendre `comparer()`/le manifeste pour exclure les fichiers hors du périmètre d'un checkout partiel de la détection de suppression (FR-020, dépend de T021) — implémenté dès T021 via `dans_perimetre` partagé avec `checkout.py`
168 168
169 **Checkpoint**: Les six user stories fonctionnelles.169 **Checkpoint**: Les six user stories fonctionnelles.
170 170
@@ -172,9 +172,9 @@ Ce que ce module débloque une fois implémenté : `specs/001-import-photos` (T0
172 172
173 ## Phase 9: Polish & Cross-Cutting Concerns173 ## Phase 9: Polish & Cross-Cutting Concerns
174 174
175-- [ ] T038 [P] Exécuter manuellement les 5 scénarios de `specs/005-checkout-reconciliation/quickstart.md` et consigner le résultat175+- [X] T038 [P] Exécuter manuellement les 5 scénarios de `specs/005-checkout-reconciliation/quickstart.md` et consigner le résultat — **2026-09-19 : les 5 scénarios passent**, déroulés via la vraie CLI (`python -m regine_cli.archive_cmd`) avec de vrais fichiers ; un bug réel a été trouvé et corrigé en cours de route (verrou non levé après une réconciliation sans changement, cf. T032)
176-- [ ] T039 [P] `ruff check --fix` sur `packages/regine-core` et `packages/regine-cli`176+- [X] T039 [P] `ruff check --fix` sur `packages/regine-core` et `packages/regine-cli` — 0 erreur restante, code formaté
177-- [ ] T040 Mettre à jour `contracts/regine-core-api.md` si écart d'implémentation ; puis lever les dépendances documentées dans `specs/001-import-photos/tasks.md` (T033) et `specs/004-categorisation-dossiers/tasks.md` (T024b), désormais implémentables177+- [X] T040 Mettre à jour `contracts/regine-core-api.md` si écart d'implémentation ; puis lever les dépendances documentées dans `specs/001-import-photos/tasks.md` (T033) et `specs/004-categorisation-dossiers/tasks.md` (T024b), désormais implémentables
178 178
179 ---179 ---
180 180