napkinstack 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,198 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ Fitness function 1 — Validation des manifests.
4
+
5
+ Vérifie que chaque module déclare ce qu'il doit déclarer et que son état de
6
+ cycle de vie est cohérent (docs/os/02-modules.md §6, docs/os/07-gouvernance.md §3).
7
+
8
+ Contrôles :
9
+ M1 chaque module possède un MANIFEST.yaml
10
+ M2 champs obligatoires présents
11
+ M3 valeurs de lifecycle / criticality valides
12
+ M4 responsabilité en UNE phrase (pas de "et" coordonnant deux capacités)
13
+ M5 module Déprécié → removal_date obligatoire et non dépassée
14
+ M6 contrat deprecated → removal_date obligatoire et non dépassée
15
+ M7 verbes standards déclarés (check / test au minimum)
16
+ M8 runbook obligatoire si criticality >= eleve
17
+ M9 enveloppe de fichiers complète (AGENTS.md, README.md, tests/)
18
+
19
+ Usage : nstack manifests [--root RACINE]
20
+ Sortie : 0 si tout passe, 1 sinon. Chaque échec explique la règle violée.
21
+ """
22
+
23
+ from __future__ import annotations
24
+ import sys
25
+ import datetime
26
+ from pathlib import Path
27
+
28
+ import yaml
29
+
30
+ LIFECYCLES = {"Proposé", "Actif", "Maintenance", "Déprécié", "Retiré"}
31
+ CRITICALITIES = {"prototype", "standard", "eleve", "critique"}
32
+ REQUIRED_FIELDS = ["name", "responsibility", "owner", "lifecycle", "criticality"]
33
+ REQUIRED_COMMANDS = ["check", "test"]
34
+ MODULE_DIRS = ["modules", "services", "apps", "packages", "contracts", "platform"]
35
+ MODULE_BASES = ["modules", "services", "apps", "packages"] # bases dont chaque dossier est un module
36
+ SECTIONS = {"module": dict, "provides": list, "consumes": list, "data": dict,
37
+ "commands": dict, "docs": dict, "dependencies": list}
38
+ TYPES = {dict: "dictionnaire", list: "liste"}
39
+
40
+ failures: list[str] = []
41
+ warnings: list[str] = []
42
+
43
+
44
+ def fail(module: str, rule: str, message: str) -> None:
45
+ failures.append(f"[{rule}] {module}\n {message}")
46
+
47
+
48
+ def warn(module: str, rule: str, message: str) -> None:
49
+ warnings.append(f"[{rule}] {module}\n {message}")
50
+
51
+
52
+ def find_manifests(root: Path) -> list[Path]:
53
+ found = []
54
+ for base in MODULE_DIRS:
55
+ d = root / base
56
+ if not d.is_dir():
57
+ continue
58
+ for manifest in sorted(d.glob("*/MANIFEST.yaml")):
59
+ found.append(manifest)
60
+ direct = d / "MANIFEST.yaml"
61
+ if direct.is_file():
62
+ found.append(direct)
63
+ return found
64
+
65
+
66
+ def find_orphans(root: Path) -> list[Path]:
67
+ """Dossiers de module sans MANIFEST.yaml (M1)."""
68
+ return [enfant for base in MODULE_BASES if (root / base).is_dir()
69
+ for enfant in sorted((root / base).iterdir())
70
+ if enfant.is_dir() and not enfant.name.startswith(".") and not (enfant / "MANIFEST.yaml").is_file()]
71
+
72
+
73
+ def parse_date(value) -> datetime.date | None:
74
+ if isinstance(value, datetime.date):
75
+ return value
76
+ try:
77
+ return datetime.date.fromisoformat(str(value))
78
+ except ValueError:
79
+ return None
80
+
81
+
82
+ def check_manifest(path: Path, today: datetime.date) -> None:
83
+ rel = path.parent.name
84
+ try:
85
+ data = yaml.safe_load(path.read_text(encoding="utf-8")) or {}
86
+ except yaml.YAMLError as exc:
87
+ fail(rel, "M2", f"MANIFEST.yaml illisible : {exc}")
88
+ return
89
+ if not isinstance(data, dict):
90
+ fail(rel, "M2", "MANIFEST.yaml doit être un dictionnaire YAML (sections module, commands, docs…).")
91
+ return
92
+ for section, attendu in SECTIONS.items():
93
+ if data.get(section) is not None and not isinstance(data[section], attendu):
94
+ fail(rel, "M2", f"section {section} : {TYPES[attendu]} attendu, "
95
+ f"{type(data[section]).__name__} trouvé.")
96
+ data[section] = attendu()
97
+
98
+ mod = data.get("module") or {}
99
+
100
+ # M2 — champs obligatoires
101
+ for field in REQUIRED_FIELDS:
102
+ if not mod.get(field):
103
+ fail(rel, "M2", f"champ obligatoire manquant : module.{field}")
104
+
105
+ # M3 — valeurs valides
106
+ lifecycle = mod.get("lifecycle")
107
+ if lifecycle and lifecycle not in LIFECYCLES:
108
+ fail(rel, "M3", f"lifecycle invalide : '{lifecycle}'. Attendu : {sorted(LIFECYCLES)}")
109
+ criticality = mod.get("criticality")
110
+ if criticality and criticality not in CRITICALITIES:
111
+ fail(rel, "M3", f"criticality invalide : '{criticality}'. Attendu : {sorted(CRITICALITIES)}")
112
+
113
+ # M4 — responsabilité en une phrase
114
+ resp = (mod.get("responsibility") or "").strip()
115
+ if resp:
116
+ if resp.count(".") > 1:
117
+ warn(rel, "M4", "responsabilité en plusieurs phrases : le module fait-il deux choses ?")
118
+ if " et " in resp.lower() and len(resp.split()) > 12:
119
+ warn(rel, "M4", f"responsabilité contient 'et' : capacité cohérente ? → \"{resp}\"")
120
+
121
+ # M5 — dépréciation du module
122
+ if lifecycle == "Déprécié":
123
+ dep = mod.get("deprecation")
124
+ dep = dep if isinstance(dep, dict) else {}
125
+ removal = parse_date(dep.get("removal_date"))
126
+ if not removal:
127
+ fail(rel, "M5", "module Déprécié sans module.deprecation.removal_date valide (AAAA-MM-JJ)")
128
+ elif removal < today:
129
+ fail(rel, "M5", f"date de retrait dépassée ({removal}). État intermédiaire permanent — "
130
+ "retirer le module ou superséder la décision.")
131
+
132
+ # M6 — dépréciation des contrats produits
133
+ for provided in data.get("provides") or []:
134
+ if not isinstance(provided, dict):
135
+ fail(rel, "M2", "entrée de provides : dictionnaire attendu (contract, version, stability).")
136
+ continue
137
+ if provided.get("stability") == "deprecated":
138
+ name = f"{provided.get('contract')}@{provided.get('version')}"
139
+ removal = parse_date(provided.get("removal_date"))
140
+ if not removal:
141
+ fail(rel, "M6", f"contrat déprécié {name} sans removal_date")
142
+ elif removal < today:
143
+ fail(rel, "M6", f"contrat {name} : date de retrait dépassée ({removal}). "
144
+ "Terminer la contraction (docs/os/03-contrats.md §4).")
145
+
146
+ # M7 — verbes standards
147
+ commands = data.get("commands") or {}
148
+ for verb in REQUIRED_COMMANDS:
149
+ if not commands.get(verb):
150
+ fail(rel, "M7", f"verbe standard manquant : commands.{verb} "
151
+ "(docs/os/09-plateforme.md §2)")
152
+
153
+ # M8 — runbook si criticité élevée
154
+ if criticality in {"eleve", "critique"}:
155
+ runbook = (data.get("docs") or {}).get("runbook")
156
+ if not runbook or not (path.parent / runbook).is_file():
157
+ fail(rel, "M8", f"criticality={criticality} exige un runbook existant "
158
+ "(docs/os/08-qualite.md §7)")
159
+
160
+ # M9 — enveloppe de fichiers
161
+ for expected in ["AGENTS.md", "README.md"]:
162
+ if not (path.parent / expected).is_file():
163
+ fail(rel, "M9", f"fichier d'enveloppe manquant : {expected}")
164
+ if not (path.parent / "tests").is_dir() and criticality != "prototype":
165
+ fail(rel, "M9", "dossier tests/ absent")
166
+
167
+
168
+ def run(root: Path) -> int:
169
+ failures.clear()
170
+ warnings.clear()
171
+ today = datetime.date.today()
172
+ manifests = find_manifests(root)
173
+ orphans = find_orphans(root)
174
+ if not manifests and not orphans:
175
+ print("Aucun MANIFEST.yaml trouvé. Rien à valider.")
176
+ return 0
177
+ for orphan in orphans:
178
+ fail(orphan.name, "M1", f"{orphan.relative_to(root)}/ n'a pas de MANIFEST.yaml. "
179
+ "Action : le créer, ou créer le module avec nstack new-module.")
180
+
181
+ for manifest in manifests:
182
+ check_manifest(manifest, today)
183
+
184
+ print(f"Manifests analysés : {len(manifests)}")
185
+ for w in warnings:
186
+ print(f" AVERTISSEMENT {w}")
187
+ for f in failures:
188
+ print(f" ÉCHEC {f}")
189
+
190
+ if failures:
191
+ print(f"\n{len(failures)} violation(s). Voir docs/os/02-modules.md et docs/os/07-gouvernance.md.")
192
+ return 1
193
+ print("Manifests : conformes.")
194
+ return 0
195
+
196
+
197
+ if __name__ == "__main__":
198
+ sys.exit(run(Path(sys.argv[1] if len(sys.argv) > 1 else ".").resolve()))
@@ -0,0 +1,79 @@
1
+ #!/usr/bin/env bash
2
+ # Fitness function 3 — Périmètre et taille de la PR.
3
+ #
4
+ # P1 une PR = un module → BLOQUANT (docs/os/02-modules.md §7)
5
+ # P2 budget de revue respecté → AVERTISSEMENT (docs/os/05-workflow.md §4)
6
+ #
7
+ # Le label `cross-module` lève P1. Le label `hors-budget` documente P2.
8
+ # Les deux sont volontairement visibles : ils rendent l'exception comptable
9
+ # (docs/os/10-mesure.md §3).
10
+ #
11
+ # Usage local : nstack pr-scope [--base BASE] [--root RACINE] (base par défaut : origin/main)
12
+ # En CI : PR_LABELS="cross-module,bug" bash src/napkinstack/fitness/pr_scope.sh "$BASE_SHA"
13
+
14
+ set -uo pipefail
15
+
16
+ BASE="${1:-origin/main}"
17
+ LABELS="${PR_LABELS:-}"
18
+ MAX_LINES="${MAX_LINES:-400}"
19
+ MAX_FILES="${MAX_FILES:-15}"
20
+
21
+ if ! git rev-parse --verify "$BASE" >/dev/null 2>&1; then
22
+ echo "Base '$BASE' introuvable — check ignoré."
23
+ exit 0
24
+ fi
25
+
26
+ CHANGED=$(git diff --name-only "$BASE"...HEAD)
27
+ [ -z "$CHANGED" ] && { echo "Aucun fichier modifié."; exit 0; }
28
+
29
+ # Modules touchés (modules/, services/, apps/, packages/)
30
+ MODULES=$(echo "$CHANGED" \
31
+ | grep -E '^(modules|services|apps|packages)/[^/]+/' \
32
+ | cut -d/ -f1-2 | sort -u)
33
+ COUNT=$(echo "$MODULES" | grep -c . || true)
34
+
35
+ echo "Fichiers modifiés : $(echo "$CHANGED" | wc -l | tr -d ' ')"
36
+ echo "Modules touchés : $COUNT"
37
+ [ "$COUNT" -gt 0 ] && echo "$MODULES" | sed 's/^/ - /'
38
+
39
+ STATUS=0
40
+
41
+ # --- P1 : une PR = un module ---------------------------------------------
42
+ if [ "$COUNT" -gt 1 ]; then
43
+ if echo "$LABELS" | grep -q 'cross-module'; then
44
+ echo
45
+ echo "AVERTISSEMENT [P1] PR cross-module autorisée par label."
46
+ echo " Comptée comme exception. Un taux qui monte = frontière qui se dégrade."
47
+ else
48
+ echo
49
+ echo "ÉCHEC [P1] Cette PR touche $COUNT modules."
50
+ echo " Une PR = un module (docs/os/02-modules.md §7)."
51
+ echo " Un changement de contrat se fait en séquence expand/contract,"
52
+ echo " jamais en une PR unique (docs/os/03-contrats.md §4)."
53
+ echo " Si l'exception est justifiée : ajouter le label 'cross-module'."
54
+ STATUS=1
55
+ fi
56
+ fi
57
+
58
+ # --- P2 : budget de revue ------------------------------------------------
59
+ STATS=$(git diff --numstat "$BASE"...HEAD \
60
+ | grep -vE '(package-lock\.json|yarn\.lock|pnpm-lock\.yaml|Cargo\.lock|go\.sum|uv\.lock|\.generated\.|/generated/)' \
61
+ || true)
62
+ LINES=$(echo "$STATS" | awk '{ a += $1 + $2 } END { print a+0 }')
63
+ FILES=$(echo "$STATS" | grep -c . || true)
64
+
65
+ echo
66
+ echo "Budget de revue : ${LINES}/${MAX_LINES} lignes, ${FILES}/${MAX_FILES} fichiers"
67
+
68
+ if [ "$LINES" -gt "$MAX_LINES" ] || [ "$FILES" -gt "$MAX_FILES" ]; then
69
+ if echo "$LABELS" | grep -q 'hors-budget'; then
70
+ echo "AVERTISSEMENT [P2] Hors budget, justifié par label."
71
+ else
72
+ echo "AVERTISSEMENT [P2] Hors budget de revue."
73
+ echo " Le débit du projet est le débit de VÉRIFICATION, pas de génération."
74
+ echo " Redécouper, ou ajouter le label 'hors-budget' avec justification"
75
+ echo " (génération, migration mécanique, renommage massif)."
76
+ fi
77
+ fi
78
+
79
+ exit $STATUS
napkinstack/modules.py ADDED
@@ -0,0 +1,132 @@
1
+ """
2
+ Modules d'un projet : création (nstack new-module) et verbes standards (nstack bootstrap,
3
+ check, test, run), sans stack imposée (PRODUCT.md P1, PDR-0001 R5).
4
+
5
+ Chaque verbe exécute la commande déclarée dans la section `commands` du MANIFEST.yaml du
6
+ module, depuis son dossier, par le shell du système. NapkinStack ne suppose jamais un
7
+ Makefile, un package.json ni rien d'autre : le projet déclare, nstack exécute. Conventions
8
+ reprises de Nx (`nx test <projet>`) et de moon (`moon run projet:tâche`).
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import re
14
+ import shutil
15
+ import subprocess
16
+ from pathlib import Path
17
+
18
+ import yaml
19
+
20
+ from napkinstack.fitness.manifests import find_manifests
21
+
22
+ GABARIT = Path(__file__).resolve().parent / "templates" / "module"
23
+ NOM = re.compile(r"[a-z][a-z0-9-]*")
24
+ EQUIPE = re.compile(r"[A-Za-z0-9-]+/[A-Za-z0-9._-]+") # même règle que copier.yml
25
+ FACULTATIFS = {"bootstrap"} # absent : rien à préparer
26
+
27
+ RUNBOOK = """# Runbook — {nom}
28
+
29
+ > Obligatoire pour criticality={criticite} (docs/os/08-qualite.md §7).
30
+ > Un runbook vide fait échouer la CI. À remplir avant la mise en production.
31
+
32
+ ## Alertes et réponses
33
+ | Alerte | Signification | Première action |
34
+ |---|---|---|
35
+ | | | |
36
+
37
+ ## Rollback
38
+ <Procédure testée, pas supposée.>
39
+
40
+ ## Vérification post-déploiement
41
+ <Ce qu'on regarde, et pendant combien de temps.>
42
+
43
+ ## Dépendances et dégradation
44
+ <Que se passe-t-il si chaque dépendance est indisponible ?>
45
+ """
46
+
47
+
48
+ def nouveau(root: Path, nom: str, owner: str, criticite: str) -> int:
49
+ if not NOM.fullmatch(nom):
50
+ print(f"ÉCHEC [new-module] nom '{nom}' invalide : kebab-case attendu, par exemple facturation.")
51
+ return 1
52
+ if not EQUIPE.fullmatch(owner):
53
+ print(f"ÉCHEC [new-module] owner '{owner}' invalide : une équipe GitHub organisation/équipe, "
54
+ "par exemple acme/facturation (CODEOWNERS, docs/os/07-gouvernance.md §7).")
55
+ return 1
56
+ dossier = root / "modules" / nom
57
+ if dossier.exists():
58
+ print(f"ÉCHEC [new-module] modules/{nom} existe déjà.")
59
+ return 1
60
+
61
+ shutil.copytree(GABARIT, dossier)
62
+ valeurs = {"{{MODULE_NAME}}": nom, "{{OWNER}}": owner, "{{CRITICALITY}}": criticite}
63
+ for fichier in (f for f in dossier.rglob("*") if f.is_file()):
64
+ texte = fichier.read_text(encoding="utf-8")
65
+ for marque, valeur in valeurs.items():
66
+ texte = texte.replace(marque, valeur)
67
+ fichier.write_text(texte, encoding="utf-8")
68
+
69
+ runbook = criticite in {"eleve", "critique"}
70
+ if runbook:
71
+ (dossier / "docs").mkdir(exist_ok=True)
72
+ (dossier / "docs" / "runbook.md").write_text(RUNBOOK.format(nom=nom, criticite=criticite),
73
+ encoding="utf-8")
74
+ manifest = dossier / "MANIFEST.yaml"
75
+ manifest.write_text(re.sub(r"^( *)# runbook:", r"\1runbook:",
76
+ manifest.read_text(encoding="utf-8"), flags=re.M), encoding="utf-8")
77
+
78
+ codeowners = root / ".github" / "CODEOWNERS"
79
+ ligne = f"/modules/{nom}/"
80
+ if codeowners.is_file():
81
+ contenu = codeowners.read_text(encoding="utf-8")
82
+ if not any(existante.split()[:1] == [ligne] for existante in contenu.splitlines()):
83
+ codeowners.write_text(contenu.rstrip("\n") + f"\n{ligne:<31}@{owner}\n", encoding="utf-8")
84
+ else:
85
+ print(f"AVERTISSEMENT : .github/CODEOWNERS absent ; y ajouter « {ligne} @{owner} ».")
86
+
87
+ print(f"Module créé : modules/{nom} (owner {owner}, criticité {criticite})"
88
+ + (", runbook à remplir" if runbook else "") + ".")
89
+ print("\nÉtapes suivantes :")
90
+ print(" 1. ADR de création dans docs/adr/ : capacité, frontière, alternatives")
91
+ print(" 2. MANIFEST.yaml : responsabilité en UNE phrase, puis les commandes check et test de la stack")
92
+ print(f" 3. modules/{nom}/AGENTS.md : le spécifique du module, jamais le kernel")
93
+ print(f" 4. nstack fitness, puis nstack check {nom} et nstack test {nom}")
94
+ return 0
95
+
96
+
97
+ def _modules(root: Path) -> dict[str, Path]:
98
+ """Nom → dossier, pour chaque MANIFEST.yaml que reconnaissent les fitness functions."""
99
+ return {manifest.parent.name: manifest.parent for manifest in find_manifests(root)}
100
+
101
+
102
+ def verbe(root: Path, verbe: str, nom: str | None) -> int:
103
+ connus = _modules(root)
104
+ if nom is not None and nom not in connus:
105
+ print(f"ÉCHEC [{verbe}] module '{nom}' introuvable dans {root} : aucun MANIFEST.yaml à ce nom.\n"
106
+ f" Modules connus : {', '.join(connus) or 'aucun'}.")
107
+ return 1
108
+ cibles = [nom] if nom is not None else list(connus)
109
+ if not cibles:
110
+ print(f"Aucun module dans {root} : rien à exécuter.")
111
+ return 0
112
+ for cible in cibles:
113
+ manifest = connus[cible] / "MANIFEST.yaml"
114
+ try:
115
+ commandes = (yaml.safe_load(manifest.read_text(encoding="utf-8")) or {}).get("commands") or {}
116
+ except yaml.YAMLError as erreur:
117
+ print(f"ÉCHEC [{verbe}] {manifest} illisible : {erreur}\n Action : nstack manifests.")
118
+ return 1
119
+ commande = commandes.get(verbe)
120
+ if not commande:
121
+ if verbe in FACULTATIFS:
122
+ print(f"→ {cible} : {verbe} non déclaré, rien à préparer.")
123
+ continue
124
+ print(f"ÉCHEC [{verbe}] module '{cible}' : commands.{verbe} non déclarée dans {manifest}.\n"
125
+ " Action : y déclarer la commande de la stack du module (docs/os/09-plateforme.md §2).")
126
+ return 1
127
+ print(f"→ {cible} : {commande}", flush=True)
128
+ code = subprocess.run(commande, shell=True, cwd=connus[cible]).returncode
129
+ if code:
130
+ print(f"ÉCHEC [{verbe}] module '{cible}' : `{commande}` sort en {code}.")
131
+ return code
132
+ return 0
napkinstack/project.py ADDED
@@ -0,0 +1,170 @@
1
+ """
2
+ Création et mise à jour d'un projet (PDR-0001), par Copier (ADR-0001).
3
+
4
+ Copier est piloté par son API, jamais en mode « unsafe ». Ses refus arrivent avant toute
5
+ modification et sont traduits en messages qui nomment la règle, l'endroit et l'action (P6).
6
+
7
+ Une mise à jour part d'un état commité et pose la version cible, fusionnée avec les
8
+ adaptations du projet, sur la branche nstack/update-<version>. Un conflit n'est jamais
9
+ commité : il reste marqué dans le fichier pour l'équipe, et le hook check-merge-conflict
10
+ comme la CI refusent tout marqueur restant.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import re
16
+ import subprocess
17
+ from pathlib import Path
18
+
19
+ import yaml
20
+
21
+ from napkinstack import __version__
22
+
23
+ SOURCE = "https://github.com/NapkinStack/engineering-os.git"
24
+ ANSWERS = ".copier-answers.yml"
25
+ DOWNGRADE = re.compile(r"You are downgrading from (\S+) to (\S+)\.")
26
+
27
+
28
+ def default_ref() -> str:
29
+ """Le squelette de la version du moteur : ils montent ensemble (PDR-0001 R2)."""
30
+ return f"v{__version__}"
31
+
32
+
33
+ def _git(root: Path, *args: str) -> subprocess.CompletedProcess[str]:
34
+ return subprocess.run(["git", *args], cwd=root, capture_output=True, text=True)
35
+
36
+
37
+ def _answers(root: Path) -> dict:
38
+ return yaml.safe_load((root / ANSWERS).read_text(encoding="utf-8")) or {}
39
+
40
+
41
+ def _explain(exc: Exception, command: str, where: Path, source: str, ref: str) -> str:
42
+ """Traduit un refus de Copier (P6)."""
43
+ from copier.errors import UnsafeTemplateError
44
+
45
+ text = str(exc).strip()
46
+ action = " Action : "
47
+ if isinstance(exc, UnsafeTemplateError):
48
+ return (f"ÉCHEC [{command}] Le gabarit {source} exécute du code ({text.splitlines()[0]}) : "
49
+ f"refusé (ADR-0001).\n{action}vérifier --source ; NapkinStack n'active jamais ces fonctions.")
50
+ if text.startswith("Validation error for question '"):
51
+ question, _, detail = text.removeprefix("Validation error for question '").partition("': ")
52
+ return (f"ÉCHEC [{command}] Réponse refusée pour {question} : {detail.strip()}\n"
53
+ f"{action}relancer avec une valeur conforme.")
54
+ if text.startswith("Destination repository is dirty"):
55
+ return (f"ÉCHEC [{command}] Arbre de travail modifié dans {where} : une mise à jour part d'un "
56
+ f"état commité (PDR-0001).\n{action}commiter ou remiser (git stash), puis relancer.")
57
+ if match := DOWNGRADE.search(text):
58
+ return (f"ÉCHEC [{command}] Version cible {match[2]} antérieure à celle du projet ({match[1]}) : "
59
+ f"pas de retour arrière (PDR-0001).\n{action}utiliser nstack {match[1]} ou plus récent.")
60
+ if text.startswith("Updating is only supported in git-tracked subprojects"):
61
+ return (f"ÉCHEC [{command}] {where} n'est pas un dépôt git : la fusion s'appuie sur "
62
+ f"l'historique.\n{action}git init, commit, puis relancer.")
63
+ if text.startswith("Cannot update: version from last update not detected"):
64
+ return (f"ÉCHEC [{command}] Le projet ne vient pas d'une version publiée (_commit de {ANSWERS}) : "
65
+ f"aucune base de fusion.\n{action}créer le projet depuis un tag vX.Y.Z.")
66
+ if isinstance(exc, OSError) or text == "Local template must be a directory.":
67
+ detail = [line.split("|", 1)[-1].strip() for line in text.splitlines() if line.strip()][-1]
68
+ return (f"ÉCHEC [{command}] Gabarit {source} en version {ref} inaccessible : {detail}\n"
69
+ f"{action}vérifier --source et --ref (tag vX.Y.Z), et l'accès au réseau.")
70
+ return f"ÉCHEC [{command}] Copier : {text}"
71
+
72
+
73
+ def init(destination: Path, answers: dict[str, str | None], source: str, ref: str) -> int:
74
+ import copier
75
+ from copier.errors import CopierError
76
+
77
+ from napkinstack.doctor import CHECKLIST
78
+
79
+ destination = destination.resolve()
80
+ if destination.exists() and (not destination.is_dir() or any(destination.iterdir())):
81
+ print(f"ÉCHEC [init] {destination} n'est pas vide : nstack init crée un projet neuf.\n"
82
+ " Action : choisir un dossier absent ou vide.")
83
+ return 1
84
+ data = {question: value for question, value in answers.items() if value is not None}
85
+ try:
86
+ copier.run_copy(source, destination, data=data, vcs_ref=ref, quiet=True, unsafe=False)
87
+ except (CopierError, ValueError, OSError) as exc:
88
+ print(_explain(exc, "init", destination, source, ref))
89
+ return 1
90
+
91
+ created = _answers(destination)
92
+ version = created.get("_commit", ref)
93
+ for args in (("init", "--quiet", "--initial-branch=main"), ("add", "--all"),
94
+ ("commit", "--quiet", "--message", f"Création du projet, NapkinStack {version}")):
95
+ result = _git(destination, *args)
96
+ if result.returncode:
97
+ print(f"ÉCHEC [init] Projet généré dans {destination}, mais `git {args[0]}` a échoué :\n"
98
+ f" {result.stderr.strip()}\n"
99
+ " Action : corriger (identité : git config user.name et user.email), "
100
+ "puis git add --all && git commit.")
101
+ return 1
102
+
103
+ print(f"Projet créé dans {destination}, NapkinStack {version}, commit initial sur main.")
104
+ print("\nÉtapes suivantes :")
105
+ print(f" 1. cd {destination} && pre-commit install")
106
+ print(f" 2. Créer le dépôt GitHub {created.get('github_repo')}, y pousser main, puis appliquer "
107
+ "ces réglages :")
108
+ for _, reglage, _ in CHECKLIST:
109
+ print(f" - [ ] {reglage}")
110
+ print(" 3. Vérifier le poste et GitHub, en lecture seule : nstack doctor (jeton : voir le README)")
111
+ return 0
112
+
113
+
114
+ def update(root: Path, ref: str) -> int:
115
+ import copier
116
+ from copier.errors import CopierError
117
+
118
+ if not (root / ANSWERS).is_file():
119
+ print(f"ÉCHEC [update] {ANSWERS} introuvable dans {root} : ce dossier n'est pas un projet "
120
+ "créé par nstack init.\n Action : lancer la commande à la racine du projet, "
121
+ "ou préciser --root.")
122
+ return 1
123
+ previous = str(_answers(root).get("_commit"))
124
+ if previous == ref:
125
+ print(f"Déjà à jour : NapkinStack {ref}.")
126
+ return 0
127
+ branch = f"nstack/update-{ref}"
128
+ if _git(root, "rev-parse", "--verify", "--quiet", f"refs/heads/{branch}").returncode == 0:
129
+ print(f"ÉCHEC [update] La branche {branch} existe déjà dans {root}.\n"
130
+ " Action : la fusionner ou la supprimer (git branch -D), puis relancer.")
131
+ return 1
132
+ try:
133
+ copier.run_update(root, vcs_ref=ref, overwrite=True, skip_answered=True, defaults=True,
134
+ conflict="inline", quiet=True, unsafe=False)
135
+ except (CopierError, ValueError, OSError) as exc:
136
+ print(_explain(exc, "update", root, str(_answers(root).get("_src_path")), ref))
137
+ return 1
138
+
139
+ if not _git(root, "status", "--porcelain").stdout.strip():
140
+ print(f"Déjà à jour : rien ne change entre NapkinStack {previous} et {ref}.")
141
+ return 0
142
+ current = str(_answers(root).get("_commit"))
143
+ switch = _git(root, "switch", "--create", branch)
144
+ if switch.returncode:
145
+ print(f"ÉCHEC [update] Branche {branch} impossible à créer : {switch.stderr.strip()}\n"
146
+ " Action : les changements restent dans l'arbre de travail ; créer la branche "
147
+ "à la main, puis commiter.")
148
+ return 1
149
+ conflicts = _git(root, "diff", "--name-only", "--diff-filter=U").stdout.splitlines()
150
+ if conflicts:
151
+ print(f"ÉCHEC [update] NapkinStack {previous} → {current} : conflits avec les adaptations "
152
+ f"du projet, marqués sur la branche {branch} dans :")
153
+ for path in conflicts:
154
+ print(f" - {path}")
155
+ print(" Action : dans chaque fichier, garder la bonne version entre <<<<<<< et >>>>>>>, "
156
+ "puis git add --all && git commit.\n Le hook check-merge-conflict et la CI "
157
+ "refusent tout marqueur restant.")
158
+ return 1
159
+ for args in (("add", "--all"), ("commit", "--quiet", "--message", f"NapkinStack {previous} → {current}")):
160
+ result = _git(root, *args)
161
+ if result.returncode:
162
+ print(f"ÉCHEC [update] Mise à jour posée sur {branch}, mais `git {args[0]}` a échoué :\n"
163
+ f" {(result.stdout + result.stderr).strip()}\n"
164
+ " Action : corriger, puis git add --all && git commit.")
165
+ return 1
166
+
167
+ print(f"Branche {branch} : NapkinStack {previous} → {current}, fusionné avec les adaptations "
168
+ "du projet.")
169
+ print(f"\nÉtape suivante : git push -u origin {branch}, puis ouvrir la PR ; la CI la valide.")
170
+ return 0