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,5 @@
1
+ """NapkinStack — moteur : fitness functions, skills et scaffold de module."""
2
+
3
+ from importlib.metadata import version
4
+
5
+ __version__ = version("napkinstack")
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()))