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
napkinstack/__init__.py
ADDED
napkinstack/cli.py
ADDED
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
"""Point d'entrée unique `nstack` (chantier C1, PDR-0001)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import argparse
|
|
6
|
+
import os
|
|
7
|
+
import subprocess
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
|
|
10
|
+
from napkinstack import __version__, doctor, modules, skills
|
|
11
|
+
from napkinstack.fitness import boundaries, manifests
|
|
12
|
+
|
|
13
|
+
PACKAGE = Path(__file__).resolve().parent
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def _root(value: str) -> Path:
|
|
17
|
+
root = Path(value).resolve()
|
|
18
|
+
if not root.is_dir():
|
|
19
|
+
raise argparse.ArgumentTypeError(f"racine introuvable : {value}")
|
|
20
|
+
return root
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def _script(relative: str, *args: str, root: Path) -> int:
|
|
24
|
+
env = {**os.environ, "NSTACK_ROOT": str(root)}
|
|
25
|
+
return subprocess.run(["bash", str(PACKAGE / relative), *args], cwd=root, env=env).returncode
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def _fitness(root: Path) -> int:
|
|
29
|
+
results = [manifests.run(root), boundaries.run(root), skills.run(root, check_only=True)]
|
|
30
|
+
return 1 if any(results) else 0
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def _add(sub, name: str, help_: str, func) -> argparse.ArgumentParser:
|
|
34
|
+
parser = sub.add_parser(name, help=help_)
|
|
35
|
+
parser.add_argument("--root", type=_root, default=Path.cwd(),
|
|
36
|
+
help="racine du projet (défaut : dossier courant)")
|
|
37
|
+
parser.set_defaults(func=func)
|
|
38
|
+
return parser
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def _init(args: argparse.Namespace) -> int:
|
|
42
|
+
from napkinstack import project # Copier ne se charge que pour init et update
|
|
43
|
+
|
|
44
|
+
answers = {"project_name": args.project_name, "github_repo": args.github_repo,
|
|
45
|
+
"owner_team": args.owner_team}
|
|
46
|
+
return project.init(args.destination, answers, args.source or project.SOURCE,
|
|
47
|
+
args.ref or project.default_ref())
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def _update(args: argparse.Namespace) -> int:
|
|
51
|
+
from napkinstack import project
|
|
52
|
+
|
|
53
|
+
return project.update(args.root, args.ref or project.default_ref())
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def build_parser() -> argparse.ArgumentParser:
|
|
57
|
+
parser = argparse.ArgumentParser(prog="nstack", description="Moteur NapkinStack.")
|
|
58
|
+
parser.add_argument("--version", action="version", version=f"nstack {__version__}")
|
|
59
|
+
sub = parser.add_subparsers(dest="command", required=True, metavar="commande")
|
|
60
|
+
_add(sub, "manifests", "manifests, cycles de vie, dépréciations (M1–M9)",
|
|
61
|
+
lambda a: manifests.run(a.root))
|
|
62
|
+
_add(sub, "boundaries", "graphe déclaré contre graphe réel (B1–B5)",
|
|
63
|
+
lambda a: boundaries.run(a.root))
|
|
64
|
+
sk = _add(sub, "skills", "génère ou vérifie les skills (S1–S4)",
|
|
65
|
+
lambda a: skills.run(a.root, check_only=a.check))
|
|
66
|
+
sk.add_argument("--check", action="store_true", help="vérifier sans écrire")
|
|
67
|
+
_add(sub, "fitness", "manifests + frontières + skills",
|
|
68
|
+
lambda a: _fitness(a.root))
|
|
69
|
+
_add(sub, "doctor", "diagnostique le poste et les réglages GitHub, en lecture seule (PDR-0001)",
|
|
70
|
+
lambda a: doctor.run(a.root))
|
|
71
|
+
nm = _add(sub, "new-module", "crée un module et ses garde-fous, sans stack imposée",
|
|
72
|
+
lambda a: modules.nouveau(a.root, a.name, a.owner, a.criticality))
|
|
73
|
+
nm.add_argument("name", help="nom du module, kebab-case")
|
|
74
|
+
nm.add_argument("owner", help="équipe GitHub, organisation/équipe")
|
|
75
|
+
nm.add_argument("criticality", choices=["prototype", "standard", "eleve", "critique"])
|
|
76
|
+
for nom_verbe, aide in (("bootstrap", "prépare un module, ou tous (commands.bootstrap)"),
|
|
77
|
+
("check", "format, lint, types d'un module, ou de tous (commands.check)"),
|
|
78
|
+
("test", "tests d'un module, ou de tous (commands.test)")):
|
|
79
|
+
vb = _add(sub, nom_verbe, aide, lambda a, v=nom_verbe: modules.verbe(a.root, v, a.module))
|
|
80
|
+
vb.add_argument("module", nargs="?", help="nom du module (défaut : tous)")
|
|
81
|
+
rn = _add(sub, "run", "démarre un module en local (commands.run)",
|
|
82
|
+
lambda a: modules.verbe(a.root, "run", a.module))
|
|
83
|
+
rn.add_argument("module")
|
|
84
|
+
ps = _add(sub, "pr-scope", "une PR = un module, budget de revue (P1–P2)",
|
|
85
|
+
lambda a: _script("fitness/pr_scope.sh", a.base, root=a.root))
|
|
86
|
+
ps.add_argument("--base", default="origin/main")
|
|
87
|
+
ini = sub.add_parser("init", help="crée un projet à partir du squelette (PDR-0001)")
|
|
88
|
+
ini.add_argument("destination", type=Path, help="dossier du projet, absent ou vide")
|
|
89
|
+
ini.add_argument("--project-name", help="nom du projet (demandé si absent)")
|
|
90
|
+
ini.add_argument("--github-repo", help="dépôt GitHub, organisation/nom (demandé si absent)")
|
|
91
|
+
ini.add_argument("--owner-team", help="équipe GitHub du socle, organisation/équipe (demandé si absent)")
|
|
92
|
+
ini.add_argument("--source", help="gabarit : URL ou chemin (défaut : dépôt NapkinStack)")
|
|
93
|
+
ini.add_argument("--ref", help="version du squelette, tag vX.Y.Z (défaut : celle de nstack)")
|
|
94
|
+
ini.set_defaults(func=_init)
|
|
95
|
+
up = _add(sub, "update", "fusionne une version de NapkinStack sur une branche à relire (PDR-0001)",
|
|
96
|
+
_update)
|
|
97
|
+
up.add_argument("--ref", help="version cible, tag vX.Y.Z (défaut : celle de nstack)")
|
|
98
|
+
return parser
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def main(argv: list[str] | None = None) -> int:
|
|
102
|
+
args = build_parser().parse_args(argv)
|
|
103
|
+
return args.func(args)
|
napkinstack/doctor.py
ADDED
|
@@ -0,0 +1,278 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Diagnostic d'un projet, en lecture seule : le poste et les réglages GitHub (PDR-0001).
|
|
3
|
+
|
|
4
|
+
Les workflows informent ; ce sont les réglages GitHub qui bloquent, et ils ne se copient
|
|
5
|
+
pas avec le projet. CHECKLIST est affichée par nstack init, vérifiée ici, et recopiée mot
|
|
6
|
+
pour mot dans le README du squelette (un test le vérifie).
|
|
7
|
+
|
|
8
|
+
GitHub se lit avec un jeton fourni par l'humain (GH_TOKEN, sinon GITHUB_TOKEN) : à grain
|
|
9
|
+
fin, limité au dépôt, permission Administration : lecture. Sans jeton, ou si l'API refuse
|
|
10
|
+
une lecture, le réglage est « non vérifié », jamais conforme. Aucune écriture.
|
|
11
|
+
|
|
12
|
+
Contrôles :
|
|
13
|
+
L1 nstack installé à la version du projet (_commit de .copier-answers.yml)
|
|
14
|
+
L2 git et pre-commit disponibles
|
|
15
|
+
L3 hooks pre-commit installés
|
|
16
|
+
L4 PRODUCT.md absent : contexte de développement de NapkinStack (R6)
|
|
17
|
+
L5 README personnalisé : phrase de présentation écrite
|
|
18
|
+
G1–G11 réglages GitHub de CHECKLIST ; G6 non applicable hors dépôt public, et en privé
|
|
19
|
+
G1–G5 nomment l'offre ou l'option GitHub requise
|
|
20
|
+
|
|
21
|
+
Usage : nstack doctor [--root RACINE]
|
|
22
|
+
Sortie : 0 si tout est vérifié et conforme, 1 sinon.
|
|
23
|
+
"""
|
|
24
|
+
|
|
25
|
+
from __future__ import annotations
|
|
26
|
+
|
|
27
|
+
import fnmatch
|
|
28
|
+
import json
|
|
29
|
+
import os
|
|
30
|
+
import re
|
|
31
|
+
import shutil
|
|
32
|
+
import subprocess
|
|
33
|
+
import urllib.error
|
|
34
|
+
import urllib.request
|
|
35
|
+
from collections.abc import Callable
|
|
36
|
+
from pathlib import Path
|
|
37
|
+
|
|
38
|
+
import yaml
|
|
39
|
+
|
|
40
|
+
from napkinstack import __version__
|
|
41
|
+
from napkinstack.project import ANSWERS
|
|
42
|
+
|
|
43
|
+
OK, ECART, INCONNU, SANS_OBJET = "OK", "ÉCHEC", "NON VÉRIFIÉ", "NON APPLICABLE"
|
|
44
|
+
API_VERSION = "2026-03-10"
|
|
45
|
+
MARQUEUR = "<Une phrase : ce que fait ce projet.>"
|
|
46
|
+
JOBS = ("Fitness functions", "Périmètre et budget de revue", "Hooks et secrets")
|
|
47
|
+
ACTIONS_TIERCES = ("astral-sh/setup-uv",) # actions hors GitHub des workflows du squelette
|
|
48
|
+
LABELS = ("cross-module", "hors-budget")
|
|
49
|
+
PUBLIEE = re.compile(r"v\d+(\.\d+)*((a|b|rc)\d+)?(\.post\d+)?(\.dev\d+)?")
|
|
50
|
+
|
|
51
|
+
RULESET = "Settings → Rules → Rulesets, branche main"
|
|
52
|
+
SECURITE = "Settings → Advanced Security"
|
|
53
|
+
ACTIONS = "Settings → Actions → General"
|
|
54
|
+
|
|
55
|
+
CHECKLIST = [ # (règle, réglage, action)
|
|
56
|
+
("G1", "Pull request obligatoire : aucun push direct sur main",
|
|
57
|
+
f"{RULESET} : exiger une pull request avant la fusion"),
|
|
58
|
+
("G2", "Au moins 1 relecture approuvée", f"{RULESET} : au moins 1 approbation requise"),
|
|
59
|
+
("G3", "Revue des CODEOWNERS obligatoire", f"{RULESET} : exiger la revue des Code Owners"),
|
|
60
|
+
("G4", "Checks obligatoires : " + ", ".join(f"`{job}`" for job in JOBS),
|
|
61
|
+
f"{RULESET} : exiger ces checks de statut"),
|
|
62
|
+
("G5", "Secret Protection et protection au push",
|
|
63
|
+
f"{SECURITE} : activer Secret Protection et la protection au push"),
|
|
64
|
+
("G6", "Signalement privé de vulnérabilités, dépôt public (canal de `SECURITY.md`)",
|
|
65
|
+
f"{SECURITE} : activer le signalement privé de vulnérabilités"),
|
|
66
|
+
("G7", "Actions autorisées : celles de GitHub, plus " + ", ".join(f"`{a}`" for a in ACTIONS_TIERCES),
|
|
67
|
+
f"{ACTIONS} : n'autoriser que les actions de GitHub et " + ", ".join(f"{a}@*" for a in ACTIONS_TIERCES)),
|
|
68
|
+
("G8", "Actions épinglées par SHA obligatoires", f"{ACTIONS} : exiger l'épinglage des actions par SHA"),
|
|
69
|
+
("G9", "Approbation des workflows pour tout contributeur externe",
|
|
70
|
+
f"{ACTIONS} : exiger l'approbation pour tous les contributeurs externes"),
|
|
71
|
+
("G10", "Jeton des workflows en lecture seule ; Actions ne crée ni n'approuve de PR",
|
|
72
|
+
f"{ACTIONS} : permissions des workflows en lecture, sans création ni approbation de PR"),
|
|
73
|
+
("G11", "Labels " + " et ".join(f"`{label}`" for label in LABELS),
|
|
74
|
+
"Issues → Labels : créer " + " et ".join(LABELS)),
|
|
75
|
+
]
|
|
76
|
+
|
|
77
|
+
# Réglages propres aux dépôts publics, et réglages qu'un dépôt privé paie (doc GitHub, 2026-09-15).
|
|
78
|
+
PUBLIC_SEULEMENT = {"G6": "le signalement privé de vulnérabilités n'existe que pour un dépôt public ; "
|
|
79
|
+
"indiquer un canal interne dans SECURITY.md"}
|
|
80
|
+
OFFRE_PRIVEE = dict.fromkeys(("G1", "G2", "G3", "G4"),
|
|
81
|
+
"Dépôt privé : les rulesets exigent l'offre GitHub Team (organisation) ou Pro "
|
|
82
|
+
"(compte personnel) ; sans elle, rien ne bloque la fusion.")
|
|
83
|
+
OFFRE_PRIVEE["G5"] = ("Dépôt privé : Secret Protection est une option payante ; sans elle, seuls les hooks "
|
|
84
|
+
"et la CI cherchent les secrets.")
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
class NonVerifie(Exception):
|
|
88
|
+
"""Réglage illisible : jeton, permission ou réseau."""
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
class GitHub:
|
|
92
|
+
"""Lectures de l'API REST de GitHub, mises en cache ; jamais d'écriture."""
|
|
93
|
+
|
|
94
|
+
def __init__(self, repo: str, token: str, api: str) -> None:
|
|
95
|
+
self.repo, self.token, self.api = repo, token, api.rstrip("/")
|
|
96
|
+
self.cache: dict[str, tuple[int, object]] = {}
|
|
97
|
+
|
|
98
|
+
def get(self, path: str, missing: bool = False):
|
|
99
|
+
"""JSON de /repos/<dépôt><path> ; None si `missing` et 404 ; NonVerifie sinon."""
|
|
100
|
+
if path not in self.cache:
|
|
101
|
+
request = urllib.request.Request(f"{self.api}/repos/{self.repo}{path}", headers={
|
|
102
|
+
"Accept": "application/vnd.github+json", "Authorization": f"Bearer {self.token}",
|
|
103
|
+
"X-GitHub-Api-Version": API_VERSION, "User-Agent": f"nstack/{__version__}"})
|
|
104
|
+
try:
|
|
105
|
+
with urllib.request.urlopen(request, timeout=10) as response:
|
|
106
|
+
self.cache[path] = (response.status, json.load(response))
|
|
107
|
+
except urllib.error.HTTPError as error:
|
|
108
|
+
self.cache[path] = (error.code, None)
|
|
109
|
+
except (OSError, ValueError):
|
|
110
|
+
self.cache[path] = (0, None)
|
|
111
|
+
status, data = self.cache[path]
|
|
112
|
+
if status == 200:
|
|
113
|
+
return data
|
|
114
|
+
if status == 404 and missing:
|
|
115
|
+
return None
|
|
116
|
+
if status == 0:
|
|
117
|
+
raise NonVerifie(f"API GitHub injoignable ({self.api})")
|
|
118
|
+
if status == 401:
|
|
119
|
+
raise NonVerifie("jeton refusé (HTTP 401)")
|
|
120
|
+
raise NonVerifie(f"lecture refusée (HTTP {status}) : dépôt inaccessible, ou permission "
|
|
121
|
+
"Administration : lecture absente du jeton")
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def _regle(gh: GitHub, kind: str) -> dict | None:
|
|
125
|
+
return next((rule for rule in gh.get("/rules/branches/main") if rule.get("type") == kind), None)
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def _parametres(gh: GitHub, kind: str) -> dict:
|
|
129
|
+
return (_regle(gh, kind) or {}).get("parameters") or {}
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def _securite(gh: GitHub) -> bool:
|
|
133
|
+
analysis = gh.get("").get("security_and_analysis")
|
|
134
|
+
if analysis is None:
|
|
135
|
+
raise NonVerifie("réglages de sécurité non visibles : permission Administration : lecture "
|
|
136
|
+
"absente du jeton")
|
|
137
|
+
return all((analysis.get(key) or {}).get("status") == "enabled"
|
|
138
|
+
for key in ("secret_scanning", "secret_scanning_push_protection"))
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def _actions_autorisees(gh: GitHub) -> bool:
|
|
142
|
+
if gh.get("/actions/permissions").get("allowed_actions") != "selected":
|
|
143
|
+
return False
|
|
144
|
+
selection = gh.get("/actions/permissions/selected-actions")
|
|
145
|
+
patterns = selection.get("patterns_allowed") or []
|
|
146
|
+
return bool(selection.get("github_owned_allowed")) and all(
|
|
147
|
+
any(fnmatch.fnmatch(f"{action}@0", pattern) for pattern in patterns) for action in ACTIONS_TIERCES)
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def _workflows(gh: GitHub) -> bool:
|
|
151
|
+
permissions = gh.get("/actions/permissions/workflow")
|
|
152
|
+
return (permissions.get("default_workflow_permissions") == "read"
|
|
153
|
+
and permissions.get("can_approve_pull_request_reviews") is False)
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
VERIFICATIONS: dict[str, Callable[[GitHub], bool]] = {
|
|
157
|
+
"G1": lambda gh: _regle(gh, "pull_request") is not None,
|
|
158
|
+
"G2": lambda gh: _parametres(gh, "pull_request").get("required_approving_review_count", 0) >= 1,
|
|
159
|
+
"G3": lambda gh: _parametres(gh, "pull_request").get("require_code_owner_review") is True,
|
|
160
|
+
"G4": lambda gh: set(JOBS) <= {check.get("context") for check in _parametres(
|
|
161
|
+
gh, "required_status_checks").get("required_status_checks", [])},
|
|
162
|
+
"G5": _securite,
|
|
163
|
+
"G6": lambda gh: gh.get("/private-vulnerability-reporting").get("enabled") is True,
|
|
164
|
+
"G7": _actions_autorisees,
|
|
165
|
+
"G8": lambda gh: gh.get("/actions/permissions").get("sha_pinning_required") is True,
|
|
166
|
+
"G9": lambda gh: gh.get("/actions/permissions/fork-pr-contributor-approval").get(
|
|
167
|
+
"approval_policy") == "all_external_contributors",
|
|
168
|
+
"G10": _workflows,
|
|
169
|
+
"G11": lambda gh: all(gh.get(f"/labels/{label}", missing=True) is not None for label in LABELS),
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
def _poste(root: Path, answers: dict) -> list[tuple[str, str, str, str]]:
|
|
174
|
+
commit = str(answers.get("_commit") or "")
|
|
175
|
+
projet = commit.removeprefix("v")
|
|
176
|
+
installer = f'uv tool install "napkinstack=={projet}" --with-executables-from pre-commit'
|
|
177
|
+
resultats = []
|
|
178
|
+
|
|
179
|
+
if not PUBLIEE.fullmatch(commit):
|
|
180
|
+
l1 = (INCONNU, f"Raison : le projet vient d'une version non publiée ({commit or 'inconnue'}).")
|
|
181
|
+
elif projet != __version__:
|
|
182
|
+
l1 = (ECART, f"nstack {__version__} installé, projet en {projet} (PDR-0001 R3).\nAction : {installer}")
|
|
183
|
+
else:
|
|
184
|
+
l1 = (OK, "")
|
|
185
|
+
resultats.append(("L1", "nstack à la version du projet", *l1))
|
|
186
|
+
|
|
187
|
+
absents = [outil for outil in ("git", "pre-commit") if shutil.which(outil) is None]
|
|
188
|
+
resultats.append(("L2", "git et pre-commit disponibles", ECART if absents else OK,
|
|
189
|
+
f"Absents : {', '.join(absents)}.\nAction : installer git ; pre-commit vient avec "
|
|
190
|
+
f"{installer}" if absents else ""))
|
|
191
|
+
|
|
192
|
+
if "git" in absents:
|
|
193
|
+
l3 = (INCONNU, "Raison : git absent.")
|
|
194
|
+
else:
|
|
195
|
+
hook = subprocess.run(["git", "rev-parse", "--git-path", "hooks/pre-commit"], cwd=root,
|
|
196
|
+
capture_output=True, text=True)
|
|
197
|
+
chemin = root / hook.stdout.strip()
|
|
198
|
+
installe = (hook.returncode == 0 and chemin.is_file()
|
|
199
|
+
and "generated by pre-commit" in chemin.read_text(errors="replace"))
|
|
200
|
+
l3 = (OK, "") if installe else (ECART, "Action : pre-commit install")
|
|
201
|
+
resultats.append(("L3", "Hooks pre-commit installés", *l3))
|
|
202
|
+
|
|
203
|
+
produit = (root / "PRODUCT.md").exists()
|
|
204
|
+
resultats.append(("L4", "PRODUCT.md absent", ECART if produit else OK,
|
|
205
|
+
"PRODUCT.md décrit le développement de NapkinStack (PDR-0001 R6).\n"
|
|
206
|
+
"Action : le supprimer." if produit else ""))
|
|
207
|
+
|
|
208
|
+
readme = root / "README.md"
|
|
209
|
+
marque = readme.is_file() and MARQUEUR in readme.read_text(encoding="utf-8", errors="replace")
|
|
210
|
+
resultats.append(("L5", "README personnalisé", ECART if marque else OK,
|
|
211
|
+
f"README.md contient encore « {MARQUEUR} ».\n"
|
|
212
|
+
"Action : écrire la phrase qui présente le projet." if marque else ""))
|
|
213
|
+
return resultats
|
|
214
|
+
|
|
215
|
+
|
|
216
|
+
def _prive(gh: GitHub) -> bool:
|
|
217
|
+
"""Dépôt non public (privé ou interne) ; visibilité illisible : traité comme public."""
|
|
218
|
+
try:
|
|
219
|
+
return (gh.get("").get("visibility") or "public") != "public"
|
|
220
|
+
except NonVerifie:
|
|
221
|
+
return False
|
|
222
|
+
|
|
223
|
+
|
|
224
|
+
def _afficher(regle: str, reglage: str, statut: str, detail: str) -> None:
|
|
225
|
+
print(f" {statut:<14} [{regle}] {reglage}")
|
|
226
|
+
for ligne in detail.splitlines():
|
|
227
|
+
print(f" {ligne}")
|
|
228
|
+
|
|
229
|
+
|
|
230
|
+
def run(root: Path) -> int:
|
|
231
|
+
if not (root / ANSWERS).is_file():
|
|
232
|
+
print(f"ÉCHEC [doctor] {ANSWERS} introuvable dans {root} : ce dossier n'est pas un projet "
|
|
233
|
+
"créé par nstack init.\n Action : lancer la commande à la racine du projet, "
|
|
234
|
+
"ou préciser --root.")
|
|
235
|
+
return 1
|
|
236
|
+
answers = yaml.safe_load((root / ANSWERS).read_text(encoding="utf-8")) or {}
|
|
237
|
+
|
|
238
|
+
resultats = _poste(root, answers)
|
|
239
|
+
print("Poste")
|
|
240
|
+
for resultat in resultats:
|
|
241
|
+
_afficher(*resultat)
|
|
242
|
+
|
|
243
|
+
token = os.environ.get("GH_TOKEN") or os.environ.get("GITHUB_TOKEN")
|
|
244
|
+
print(f"\nGitHub : {answers.get('github_repo')}")
|
|
245
|
+
if token:
|
|
246
|
+
gh = GitHub(str(answers.get("github_repo")), token,
|
|
247
|
+
os.environ.get("GITHUB_API_URL") or "https://api.github.com")
|
|
248
|
+
else:
|
|
249
|
+
gh = None
|
|
250
|
+
print(" Aucun jeton (GH_TOKEN ou GITHUB_TOKEN) : aucun réglage n'est lu.\n"
|
|
251
|
+
" Action : fournir un jeton à grain fin limité au dépôt, permission "
|
|
252
|
+
"Administration : lecture, puis relancer.")
|
|
253
|
+
prive = gh is not None and _prive(gh)
|
|
254
|
+
for regle, reglage, action in CHECKLIST:
|
|
255
|
+
if gh is None:
|
|
256
|
+
statut, detail = INCONNU, ""
|
|
257
|
+
elif prive and regle in PUBLIC_SEULEMENT:
|
|
258
|
+
statut, detail = SANS_OBJET, f"Raison : {PUBLIC_SEULEMENT[regle]}."
|
|
259
|
+
else:
|
|
260
|
+
try:
|
|
261
|
+
statut, detail = (OK, "") if VERIFICATIONS[regle](gh) else (ECART, f"Action : {action}")
|
|
262
|
+
except NonVerifie as raison:
|
|
263
|
+
statut, detail = INCONNU, f"Raison : {raison}"
|
|
264
|
+
if prive and statut != OK and regle in OFFRE_PRIVEE:
|
|
265
|
+
detail += f"\n{OFFRE_PRIVEE[regle]}"
|
|
266
|
+
resultats.append((regle, reglage, statut, detail))
|
|
267
|
+
_afficher(regle, reglage, statut, detail)
|
|
268
|
+
|
|
269
|
+
ecarts = sum(statut == ECART for _, _, statut, _ in resultats)
|
|
270
|
+
inconnus = sum(statut == INCONNU for _, _, statut, _ in resultats)
|
|
271
|
+
sans_objet = sum(statut == SANS_OBJET for _, _, statut, _ in resultats)
|
|
272
|
+
suffixe = f", {sans_objet} non applicable(s)" if sans_objet else ""
|
|
273
|
+
if not ecarts and not inconnus:
|
|
274
|
+
print(f"\nnstack doctor : conforme{suffixe}.")
|
|
275
|
+
return 0
|
|
276
|
+
print(f"\nnstack doctor : {ecarts} écart(s), {inconnus} non vérifié(s){suffixe}.\nLes workflows informent ; "
|
|
277
|
+
"ce sont les réglages GitHub qui bloquent, et ils ne se copient pas avec le projet.")
|
|
278
|
+
return 1
|
|
File without changes
|
|
@@ -0,0 +1,222 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""
|
|
3
|
+
Fitness function 2 — Frontières entre modules.
|
|
4
|
+
|
|
5
|
+
Compare le graphe DÉCLARÉ (manifests) au graphe RÉEL (références dans le code),
|
|
6
|
+
puis vérifie l'absence de cycles (docs/os/02-modules.md §8, docs/os/07-gouvernance.md §3).
|
|
7
|
+
|
|
8
|
+
Contrôles :
|
|
9
|
+
B1 aucune référence vers un module non déclaré dans `consumes`
|
|
10
|
+
B2 aucun import direct de l'implémentation d'un autre module (src/, internal/)
|
|
11
|
+
B3 aucune dépendance circulaire entre modules
|
|
12
|
+
B4 dépendance déclarée mais jamais utilisée (avertissement)
|
|
13
|
+
B5 aucun accès direct aux données d'un autre module (tables déclarées ailleurs)
|
|
14
|
+
|
|
15
|
+
DÉTECTION — à calibrer pour ton langage.
|
|
16
|
+
La détection est textuelle et volontairement simple : on cherche, dans les lignes
|
|
17
|
+
ressemblant à un import, les jetons identifiant un autre module. Deux sources :
|
|
18
|
+
- le chemin du module ("modules/billing", "@org/billing", "org.billing")
|
|
19
|
+
- le champ `code_name` du manifest, s'il diffère du nom de dossier.
|
|
20
|
+
Ajuste IMPORT_HINTS et SOURCE_SUFFIXES selon ta stack. Un faux positif se corrige
|
|
21
|
+
en déclarant la dépendance ; un faux négatif se corrige en enrichissant les motifs.
|
|
22
|
+
|
|
23
|
+
Usage : nstack boundaries [--root RACINE]
|
|
24
|
+
"""
|
|
25
|
+
|
|
26
|
+
from __future__ import annotations
|
|
27
|
+
import re
|
|
28
|
+
import sys
|
|
29
|
+
from pathlib import Path
|
|
30
|
+
|
|
31
|
+
import yaml
|
|
32
|
+
|
|
33
|
+
MODULE_DIRS = ["modules", "services", "apps", "packages"]
|
|
34
|
+
SOURCE_SUFFIXES = {
|
|
35
|
+
".py", ".ts", ".tsx", ".js", ".jsx", ".go", ".rs", ".java", ".kt",
|
|
36
|
+
".rb", ".php", ".cs", ".swift", ".scala", ".ex", ".exs",
|
|
37
|
+
}
|
|
38
|
+
SKIP_DIRS = {"node_modules", "dist", "build", "target", "vendor", ".git",
|
|
39
|
+
"__pycache__", ".venv", "venv", "coverage", "generated"}
|
|
40
|
+
IMPORT_HINTS = re.compile(
|
|
41
|
+
r"\b(import|from|require|use|using|include|#include|extern crate|go:import)\b|"
|
|
42
|
+
r"^\s*(import|from)\s", re.IGNORECASE
|
|
43
|
+
)
|
|
44
|
+
INTERNAL_MARKERS = ("/src/", "/internal/", "/lib/internal", "\\src\\")
|
|
45
|
+
|
|
46
|
+
failures: list[str] = []
|
|
47
|
+
warnings: list[str] = []
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def fail(rule: str, where: str, message: str) -> None:
|
|
51
|
+
failures.append(f"[{rule}] {where}\n {message}")
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def warn(rule: str, where: str, message: str) -> None:
|
|
55
|
+
warnings.append(f"[{rule}] {where}\n {message}")
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def load_modules(root: Path) -> dict[str, dict]:
|
|
59
|
+
modules: dict[str, dict] = {}
|
|
60
|
+
for base in MODULE_DIRS:
|
|
61
|
+
d = root / base
|
|
62
|
+
if not d.is_dir():
|
|
63
|
+
continue
|
|
64
|
+
for manifest in sorted(d.glob("*/MANIFEST.yaml")):
|
|
65
|
+
try:
|
|
66
|
+
data = yaml.safe_load(manifest.read_text(encoding="utf-8")) or {}
|
|
67
|
+
except yaml.YAMLError:
|
|
68
|
+
continue # signalé par nstack manifests (M2)
|
|
69
|
+
mod = data.get("module") if isinstance(data, dict) else None
|
|
70
|
+
if not isinstance(mod, dict):
|
|
71
|
+
continue # signalé par nstack manifests (M2)
|
|
72
|
+
consumes = data.get("consumes") if isinstance(data.get("consumes"), list) else []
|
|
73
|
+
section = data.get("data") if isinstance(data.get("data"), dict) else {}
|
|
74
|
+
owns = section.get("owns") if isinstance(section.get("owns"), list) else []
|
|
75
|
+
name = mod.get("name") or manifest.parent.name
|
|
76
|
+
modules[name] = {
|
|
77
|
+
"path": manifest.parent,
|
|
78
|
+
"dirname": manifest.parent.name,
|
|
79
|
+
"code_name": mod.get("code_name") or name,
|
|
80
|
+
"declared": {c.get("module") for c in consumes if isinstance(c, dict) and c.get("module")},
|
|
81
|
+
"owns_data": set(owns),
|
|
82
|
+
"raw": data,
|
|
83
|
+
}
|
|
84
|
+
return modules
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def iter_sources(module_path: Path):
|
|
88
|
+
for path in module_path.rglob("*"):
|
|
89
|
+
if not path.is_file() or path.suffix not in SOURCE_SUFFIXES:
|
|
90
|
+
continue
|
|
91
|
+
if any(part in SKIP_DIRS for part in path.parts):
|
|
92
|
+
continue
|
|
93
|
+
yield path
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def tokens_for(other: dict) -> list[str]:
|
|
97
|
+
"""Jetons qui identifient un autre module dans une ligne d'import."""
|
|
98
|
+
return list({
|
|
99
|
+
f"modules/{other['dirname']}",
|
|
100
|
+
f"services/{other['dirname']}",
|
|
101
|
+
f"packages/{other['dirname']}",
|
|
102
|
+
f"/{other['code_name']}/",
|
|
103
|
+
f"@{other['code_name']}",
|
|
104
|
+
f".{other['code_name']}.",
|
|
105
|
+
})
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def analyse(root: Path, modules: dict[str, dict]) -> dict[str, set[str]]:
|
|
109
|
+
real: dict[str, set[str]] = {name: set() for name in modules}
|
|
110
|
+
|
|
111
|
+
for name, mod in modules.items():
|
|
112
|
+
for source in iter_sources(mod["path"]):
|
|
113
|
+
try:
|
|
114
|
+
lines = source.read_text(encoding="utf-8", errors="ignore").splitlines()
|
|
115
|
+
except OSError:
|
|
116
|
+
continue
|
|
117
|
+
for lineno, line in enumerate(lines, 1):
|
|
118
|
+
if not IMPORT_HINTS.search(line):
|
|
119
|
+
continue
|
|
120
|
+
for other_name, other in modules.items():
|
|
121
|
+
if other_name == name:
|
|
122
|
+
continue
|
|
123
|
+
if not any(tok in line for tok in tokens_for(other)):
|
|
124
|
+
continue
|
|
125
|
+
real[name].add(other_name)
|
|
126
|
+
rel = source.relative_to(root)
|
|
127
|
+
|
|
128
|
+
# B2 — import de l'implémentation interne
|
|
129
|
+
if any(marker in line.replace("\\", "/") for marker in INTERNAL_MARKERS):
|
|
130
|
+
fail("B2", f"{rel}:{lineno}",
|
|
131
|
+
f"'{name}' importe l'implémentation interne de '{other_name}'. "
|
|
132
|
+
f"Passer par son contrat (docs/os/03-contrats.md).")
|
|
133
|
+
# B1 — dépendance non déclarée
|
|
134
|
+
elif other_name not in mod["declared"]:
|
|
135
|
+
fail("B1", f"{rel}:{lineno}",
|
|
136
|
+
f"'{name}' référence '{other_name}' sans le déclarer dans "
|
|
137
|
+
f"consumes du MANIFEST. Déclarer le contrat consommé, "
|
|
138
|
+
f"ou supprimer la dépendance.")
|
|
139
|
+
|
|
140
|
+
# B5 — accès aux données d'autrui
|
|
141
|
+
for name, mod in modules.items():
|
|
142
|
+
others_tables = {t: o for o, m in modules.items() if o != name
|
|
143
|
+
for t in m["owns_data"]}
|
|
144
|
+
if not others_tables:
|
|
145
|
+
continue
|
|
146
|
+
for source in iter_sources(mod["path"]):
|
|
147
|
+
text = source.read_text(encoding="utf-8", errors="ignore").lower()
|
|
148
|
+
for table, owner in others_tables.items():
|
|
149
|
+
if re.search(rf"\b(from|join|into|update|table)\s+[\"'`\[]?{re.escape(table.lower())}\b", text):
|
|
150
|
+
fail("B5", str(source.relative_to(root)),
|
|
151
|
+
f"'{name}' accède à la table '{table}' possédée par '{owner}'. "
|
|
152
|
+
f"Couplage par la base (docs/os/08-qualite.md §8).")
|
|
153
|
+
break
|
|
154
|
+
|
|
155
|
+
# B4 — déclaré mais inutilisé
|
|
156
|
+
for name, mod in modules.items():
|
|
157
|
+
for declared in mod["declared"]:
|
|
158
|
+
if declared in modules and declared not in real[name]:
|
|
159
|
+
warn("B4", name,
|
|
160
|
+
f"dépendance déclarée vers '{declared}' mais aucune utilisation détectée. "
|
|
161
|
+
f"Nettoyer le MANIFEST, ou ajuster les motifs de détection.")
|
|
162
|
+
return real
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
def find_cycles(graph: dict[str, set[str]]) -> list[list[str]]:
|
|
166
|
+
cycles, stack, visiting, visited = [], [], set(), set()
|
|
167
|
+
|
|
168
|
+
def walk(node: str) -> None:
|
|
169
|
+
if node in visiting:
|
|
170
|
+
cycles.append(stack[stack.index(node):] + [node])
|
|
171
|
+
return
|
|
172
|
+
if node in visited:
|
|
173
|
+
return
|
|
174
|
+
visiting.add(node)
|
|
175
|
+
stack.append(node)
|
|
176
|
+
for nxt in sorted(graph.get(node, ())):
|
|
177
|
+
walk(nxt)
|
|
178
|
+
stack.pop()
|
|
179
|
+
visiting.discard(node)
|
|
180
|
+
visited.add(node)
|
|
181
|
+
|
|
182
|
+
for node in sorted(graph):
|
|
183
|
+
walk(node)
|
|
184
|
+
return cycles
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
def run(root: Path) -> int:
|
|
188
|
+
failures.clear()
|
|
189
|
+
warnings.clear()
|
|
190
|
+
modules = load_modules(root)
|
|
191
|
+
|
|
192
|
+
if len(modules) < 2:
|
|
193
|
+
print(f"{len(modules)} module(s) : pas de frontière à vérifier.")
|
|
194
|
+
return 0
|
|
195
|
+
|
|
196
|
+
real = analyse(root, modules)
|
|
197
|
+
|
|
198
|
+
for cycle in find_cycles(real):
|
|
199
|
+
fail("B3", " → ".join(cycle),
|
|
200
|
+
"dépendance circulaire : ces modules sont devenus inséparables "
|
|
201
|
+
"(docs/os/02-modules.md §9).")
|
|
202
|
+
|
|
203
|
+
print(f"Modules analysés : {len(modules)}")
|
|
204
|
+
print("Graphe réel détecté :")
|
|
205
|
+
for name in sorted(real):
|
|
206
|
+
deps = ", ".join(sorted(real[name])) or "—"
|
|
207
|
+
print(f" {name} → {deps}")
|
|
208
|
+
|
|
209
|
+
for w in warnings:
|
|
210
|
+
print(f" AVERTISSEMENT {w}")
|
|
211
|
+
for f in failures:
|
|
212
|
+
print(f" ÉCHEC {f}")
|
|
213
|
+
|
|
214
|
+
if failures:
|
|
215
|
+
print(f"\n{len(failures)} violation(s) de frontière.")
|
|
216
|
+
return 1
|
|
217
|
+
print("Frontières : conformes.")
|
|
218
|
+
return 0
|
|
219
|
+
|
|
220
|
+
|
|
221
|
+
if __name__ == "__main__":
|
|
222
|
+
sys.exit(run(Path(sys.argv[1] if len(sys.argv) > 1 else ".").resolve()))
|