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.
- napkinstack/__init__.py +5 -0
- napkinstack/cli.py +103 -0
- napkinstack/doctor.py +278 -0
- napkinstack/fitness/__init__.py +0 -0
- napkinstack/fitness/boundaries.py +222 -0
- napkinstack/fitness/manifests.py +198 -0
- napkinstack/fitness/pr_scope.sh +79 -0
- napkinstack/modules.py +132 -0
- napkinstack/project.py +170 -0
- napkinstack/skills.py +159 -0
- napkinstack/templates/module/AGENTS.md +36 -0
- napkinstack/templates/module/MANIFEST.yaml +77 -0
- napkinstack/templates/module/README.md +27 -0
- napkinstack/templates/module/docs/adr/.gitkeep +0 -0
- napkinstack/templates/module/src/.gitkeep +0 -0
- napkinstack/templates/module/tests/.gitkeep +0 -0
- napkinstack-0.1.0.dist-info/METADATA +139 -0
- napkinstack-0.1.0.dist-info/RECORD +21 -0
- napkinstack-0.1.0.dist-info/WHEEL +4 -0
- napkinstack-0.1.0.dist-info/entry_points.txt +3 -0
- napkinstack-0.1.0.dist-info/licenses/LICENSE +21 -0
|
@@ -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
|