kiln-cli 0.3.1__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.
kiln/CHANGELOG.md ADDED
@@ -0,0 +1,25 @@
1
+ # Nouveautés de kiln
2
+
3
+ Une entrée par version, la plus récente en tête : `## vX.Y.Z — date`, puis une ligne par
4
+ changement visible (`- …`), cassants en tête. `kiln update` montre les entrées des versions
5
+ qu'un projet passe ; la skill `base:version` rédige celle de la version qu'elle sort, sur la
6
+ branche `release/vX.Y.Z`, avant sa première candidate.
7
+
8
+ ## v0.3.1 — 2026-10-16
9
+ - **Cassant** : le suivi du travail passe en anglais, `docs/a-faire/` devient `docs/backlog/` (tickets `T-NN`, skill `base:backlog` à la place de `base:a-faire`) ; `kiln update` déplace l'ancien dossier.
10
+ - Numérotation comme Claude Code : en 0.x, chaque version monte le correctif, cassante comprise ; la mineure ne monte que pour une étape (`kiln version --minor`). Une version est promue dès que la suivante est prête en candidate, sans délai d'attente.
11
+ - kiln s'installe depuis PyPI : paquet `kiln-cli` (la commande reste `kiln`), publié au tag de chaque version promue ; `kiln doctor` et `maj-kiln` proposent la mise à jour par là.
12
+ - Nouveaux skills : `report-to-kiln` (un défaut de kiln rencontré dans un repo devient une issue ou une PR, publiée seulement avec l'accord de l'utilisateur), `token` (jeton d'un service dans `.<service>/`, collé sans s'afficher), `contribution` (traiter la PR d'un contributeur extérieur).
13
+ - La CI générée lance gitleaks et peut être appelée par d'autres workflows (`workflow_call`).
14
+ - Status line : quatre styles aux couleurs de kiln, en anglais (`kiln set statusline=…`), nom de session, RAM et cœurs occupés.
15
+ - Correctifs : `kiln doctor` propose la mise à jour au tag de la version, colonne du modèle de `kiln status` assez large.
16
+
17
+ ## v0.3.0 — 2026-10-15
18
+ - Première version publiée. Semver en 0.x : une version sort d'abord en candidate (`-rc.N`) pour les postes au canal `interne`, puis est promue quand la suivante est prête et qu'elle a tenu trois jours ; `kiln version` dit où en sont les candidates.
19
+ - Les projets citent les workflows de kiln au tag exact dont ils partent ; `kiln update` le fait avancer et montre les nouveautés de chaque version passée. Le hook de début de session propose l'outil puis le repo à chaque version.
20
+ - `kiln status` dit de quelle version part chaque repo (`f611f82 → v0.3.0`) ; `kiln set canal=interne` (réglage du poste) fait suivre les candidates.
21
+ - Nouvelle interface : four rieur en bannière, écran d'accueil par sections, roue animée et durée pour chaque commande externe, bilan encadré des étapes ; aucune couleur hors terminal, `KILN_VERBOSE=1` pour la sortie complète.
22
+ - `kiln doctor` : diagnostic du poste et du repo, chaque problème avec la commande qui le corrige.
23
+ - `kiln dev` : sorties des serveurs entrelacées, préfixées par service en couleur.
24
+ - `kiln update --all` fusionne directement chaque repo dont les contrôles passent, sans PR.
25
+ - Backend Java (Maven), skill `maj-kiln`, lien `.claude/kiln` vers le plugin, réglages du poste par `kiln set`.
kiln/__init__.py ADDED
@@ -0,0 +1 @@
1
+ """kiln : génère un projet complet et le tient à jour."""
kiln/backlog.py ADDED
@@ -0,0 +1,168 @@
1
+ """Suivi du travail : passage de l'ancien `docs/a-faire/` (en français) à `docs/backlog/`.
2
+
3
+ `kiln update` l'appelle après Copier : le modèle a posé un `docs/backlog/` vierge, que le suivi
4
+ du repo remplace, converti (titres, champs et états en anglais, `AF-NN` devenu `T-NN`) ; le
5
+ texte des tickets reste tel qu'écrit. Copier a déjà supprimé `docs/a-faire/`, même modifié :
6
+ son contenu se relit dans le dernier commit (`kiln update` part d'un arbre propre).
7
+ """
8
+
9
+ import re
10
+ import shutil
11
+ from pathlib import Path
12
+
13
+ from kiln.shell import KilnError, capture
14
+
15
+ LEGACY_DIR = Path("docs/a-faire")
16
+ BACKLOG_DIR = Path("docs/backlog")
17
+
18
+ STATUSES = (
19
+ ("à faire", "todo"),
20
+ ("en cours — branche", "in progress — branch"),
21
+ ("bloqué — attend", "blocked — waiting for"),
22
+ ("à vérifier", "to verify"),
23
+ )
24
+ SECTIONS = (
25
+ ("## 🔴 Critique", "## 🔴 Critical"),
26
+ ("## 🟠 Important", "## 🟠 Important"),
27
+ ("## 🟡 Normal", "## 🟡 Normal"),
28
+ ("## ⚪ Fond", "## ⚪ Background — long-running work, debt and ideas"),
29
+ ("## ✅ Fait", "## ✅ Done"),
30
+ ("### Journal", "### Log"),
31
+ )
32
+ REJECTED_HEADINGS = (
33
+ ("# Écarté", "# Rejected"),
34
+ ("## Exclu pour de bon", "## Ruled out for good"),
35
+ ("## Refusé pour cette saison", "## Declined for now"),
36
+ ("## Sans suite", "## Dropped"),
37
+ )
38
+ LEGACY_INTROS = (
39
+ "Trois catégories, à ne pas confondre.",
40
+ "Décisions définitives : à ne pas reproposer, même si un audit le recommande.",
41
+ )
42
+
43
+
44
+ def migrate_legacy_backlog(root: Path) -> bool:
45
+ """Convertit `docs/a-faire/` en `docs/backlog/` s'il existe encore ; dit s'il l'a fait."""
46
+ old_backlog = read_legacy(root, "a-faire.md")
47
+ if old_backlog is None:
48
+ return False
49
+ backlog = root / BACKLOG_DIR
50
+ backlog.mkdir(parents=True, exist_ok=True)
51
+ fresh = backlog / "backlog.md"
52
+ template_text = fresh.read_text() if fresh.exists() else ""
53
+ dates = opened(root, (LEGACY_DIR / "a-faire.md").as_posix())
54
+ fresh.write_text(convert_backlog(old_backlog, template_text, dates))
55
+ if (rejected := read_legacy(root, "ecarte.md")) is not None:
56
+ (backlog / "rejected.md").write_text(convert_rejected(rejected))
57
+ if (urgent := read_legacy(root, "urgent.md")) is not None:
58
+ urgent_path = backlog / "urgent.md"
59
+ intro = urgent_path.read_text() if urgent_path.exists() else "# Urgent\n"
60
+ urgent_path.write_text(convert_urgent(urgent, intro))
61
+ legacy = root / LEGACY_DIR
62
+ if legacy.exists():
63
+ for name in ("a-faire.md", "ecarte.md", "urgent.md", "README.md"):
64
+ (legacy / name).unlink(missing_ok=True)
65
+ # Comptes rendus d'audit en cours : ils suivent le suivi.
66
+ for leftover in legacy.iterdir():
67
+ shutil.move(leftover, backlog / leftover.name)
68
+ legacy.rmdir()
69
+ return True
70
+
71
+
72
+ def read_legacy(root: Path, name: str) -> str | None:
73
+ """Un fichier de l'ancien suivi : sur le disque, sinon dans le dernier commit."""
74
+ path = root / LEGACY_DIR / name
75
+ if path.exists():
76
+ return path.read_text()
77
+ try:
78
+ return capture(["git", "show", f"HEAD:{(LEGACY_DIR / name).as_posix()}"], root) + "\n"
79
+ except KilnError:
80
+ return None
81
+
82
+
83
+ def opened(root: Path, path: str) -> dict[str, str]:
84
+ """Date d'ouverture de chaque ticket : le premier commit où son titre apparaît."""
85
+ dates: dict[str, str] = {}
86
+ try:
87
+ log = capture(["git", "log", "--reverse", "--format=commit %as", "-p", "--", path], root)
88
+ except KilnError:
89
+ return dates
90
+ day = ""
91
+ for line in log.splitlines():
92
+ if line.startswith("commit "):
93
+ day = line.removeprefix("commit ")
94
+ elif match := re.match(r"\+### AF-(\d+) ·", line):
95
+ dates.setdefault(match[1], day)
96
+ return dates
97
+
98
+
99
+ def convert_backlog(text: str, template_text: str, dates: dict[str, str]) -> str:
100
+ """`a-faire.md` en `backlog.md` : en-tête et mode d'emploi du modèle, tickets convertis."""
101
+ next_free = re.search(r"Prochain libre : AF-(\d+)", text)
102
+ compass = re.search(r"La boussole est \*\*(.+?)\*\*", text, re.S)
103
+ started = re.search(r"Créée le\s+\*\*(.+?)\*\*", text)
104
+ body = text[text.index("## 🔴") :] if "## 🔴" in text else text
105
+ body = convert_tickets(body, dates)
106
+ header = template_text[: template_text.index("## 🔴")] if "## 🔴" in template_text else ""
107
+ if next_free:
108
+ header = re.sub(r"Next free: T-\d+", f"Next free: T-{next_free[1]}", header)
109
+ if compass:
110
+ header = re.sub(r"North star: \*\*.*?\*\*", f"North star: **{compass[1]}**", header)
111
+ if started:
112
+ header = re.sub(r"Started on\s+\*\*.*?\*\*", f"Started on\n**{started[1]}**", header)
113
+ return header + body
114
+
115
+
116
+ def convert_tickets(text: str, dates: dict[str, str]) -> str:
117
+ lines: list[str] = []
118
+ ticket = ""
119
+ for line in text.splitlines():
120
+ for old, new in SECTIONS:
121
+ if line.startswith(old):
122
+ line = new
123
+ if match := re.match(r"### AF-(\d+) ·", line):
124
+ ticket = match[1]
125
+ elif line.startswith("- **État** : "):
126
+ status = line.removeprefix("- **État** : ")
127
+ for old, new in STATUSES:
128
+ if status.startswith(old):
129
+ status = new + status.removeprefix(old)
130
+ status = re.sub(r"depuis le ", "since ", status)
131
+ lines += ["- **Type**: task", f"- **Status**: {status}"]
132
+ lines.append(f"- **Opened**: {dates.get(ticket) or '—'}")
133
+ continue
134
+ line = line.replace("- **Effort** : ", "- **Effort**: ")
135
+ line = line.replace(" · **Qui** : ", " · **Owner**: ")
136
+ line = line.replace(" · **Échéance** : ", " · **Due**: ")
137
+ line = line.replace("- **Origine** : ", "- **Origin**: ")
138
+ if line.startswith("Une ligne par entrée terminée"):
139
+ line = "One line per closed ticket, most recent first:"
140
+ line = line.replace(
141
+ "`- **AAAA-MM-JJ** · AF-NN · Titre — ce qui a été fait (branche)`.",
142
+ "`- **YYYY-MM-DD** · T-NN · Title — what was done (branch) · opened YYYY-MM-DD`.",
143
+ )
144
+ lines.append(re.sub(r"\bAF-(\d+|NN)\b", r"T-\1", line))
145
+ return "\n".join(lines) + "\n"
146
+
147
+
148
+ def convert_rejected(text: str) -> str:
149
+ lines = []
150
+ for line in text.splitlines():
151
+ for old, new in REJECTED_HEADINGS:
152
+ if line == old:
153
+ line = new
154
+ if line == LEGACY_INTROS[0]:
155
+ line = "Three kinds, not to be confused."
156
+ elif line == LEGACY_INTROS[1]:
157
+ line = "Final decisions: do not propose them again, even if an audit recommends it."
158
+ lines.append(re.sub(r"\bAF-(\d+)\b", r"T-\1", line))
159
+ return "\n".join(lines) + "\n"
160
+
161
+
162
+ def convert_urgent(text: str, intro: str) -> str:
163
+ """Les cases d'`urgent.md` sous l'introduction du modèle, en anglais."""
164
+ lines = text.splitlines()
165
+ first_box = next((i for i, line in enumerate(lines) if line.startswith("- [")), len(lines))
166
+ boxes = "\n".join(lines[first_box:]).strip("\n")
167
+ body = intro.rstrip("\n") + "\n"
168
+ return body + f"\n{boxes}\n" if boxes else body
kiln/banner.py ADDED
@@ -0,0 +1,125 @@
1
+ """Bannière des commandes longues (`new`, `install`, `update`, `status`) et écran d'accueil.
2
+
3
+ Un four rieur en pixel art (pierre et feu), dessiné en demi-blocs (deux pixels par caractère),
4
+ à côté de la version de kiln, de la commande lancée, du projet et de son dossier.
5
+ Hors terminal, ou avec `NO_COLOR`, une seule ligne sans dessin : les journaux et les sorties
6
+ capturées restent lisibles.
7
+ """
8
+
9
+ from collections.abc import Sequence
10
+ from pathlib import Path
11
+
12
+ from kiln import ui
13
+ from kiln.shell import KilnError
14
+ from kiln.stack import NO_FRAMEWORK, answers_if_any, load_stack
15
+ from kiln.versions import installed_version
16
+
17
+ # Un caractère = deux pixels superposés. L pierre éclairée, B pierre, b pierre dans l'ombre,
18
+ # d socle et chapeau de brique, W/K yeux, P joues, R/F/Y flamme (bord, corps, cœur), . vide.
19
+ LOGO = (
20
+ "..........dd..",
21
+ "...LLLBBB.bb..",
22
+ "..LWKBBWKBbb..",
23
+ ".LLWKBBWKBBbb.",
24
+ ".LBP.RR..PBbb.",
25
+ ".LBB.RFR..Bbb.",
26
+ ".BBB.FYYF.bbb.",
27
+ "dddddddddddddd",
28
+ )
29
+ LOGO_COLORS = {
30
+ "L": ui.STONE_LIGHT,
31
+ "B": ui.STONE,
32
+ "b": ui.STONE_DARK,
33
+ "d": ui.BRICK,
34
+ "W": ui.WHITE,
35
+ "K": ui.SOOT,
36
+ "P": ui.CHEEK,
37
+ "R": ui.EMBER,
38
+ "F": ui.FIRE,
39
+ "Y": ui.GLOW,
40
+ }
41
+ EMPTY = "."
42
+ UPPER_HALF, LOWER_HALF, FULL_BLOCK = "▀", "▄", "█"
43
+ LOGO_MARGIN, TEXT_GAP, INDENT = " ", " ", " "
44
+ REPO_URL = "github.com/antoine-tena/kiln"
45
+
46
+ # Écran d'accueil : sections de commandes (nom, rôle) et exemples.
47
+ CommandGroup = tuple[str, Sequence[tuple[str, str]]]
48
+
49
+
50
+ def show_banner(action: str, root: Path | None = None) -> None:
51
+ """Affiche la bannière ; `root` : le repo concerné, sinon le dossier courant sans projet."""
52
+ kiln_version = installed_version()
53
+ details = [action, *([_project_summary(root)] if root is not None else [])]
54
+ location = _home_relative(root or Path.cwd())
55
+ if not ui.is_styled():
56
+ print(" · ".join([f"kiln {kiln_version}", *details, location]), flush=True)
57
+ return
58
+ text_lines = [
59
+ "",
60
+ f"{ui.paint('kiln', bold=True)} {ui.paint(kiln_version, ui.STONE)}",
61
+ ui.paint(" · ".join(details), ui.FIRE),
62
+ ui.paint(location, ui.STONE),
63
+ ]
64
+ print()
65
+ for logo_line, text_line in zip(render_logo(), text_lines, strict=True):
66
+ print(f"{LOGO_MARGIN}{logo_line}{TEXT_GAP}{text_line}".rstrip())
67
+ print(flush=True)
68
+
69
+
70
+ def show_home(groups: Sequence[CommandGroup], examples: Sequence[str]) -> None:
71
+ """`kiln` seul ou `kiln --help` : bannière, commandes par section, exemples."""
72
+ show_banner("atelier de Claude Code")
73
+ width = max(len(name) for _, commands in groups for name, _ in commands) + 3
74
+ usage = ui.paint("<commande> [options]", ui.STONE)
75
+ print(f"{INDENT}{ui.paint('Usage', bold=True)} kiln {usage}")
76
+ for title, commands in groups:
77
+ print(f"\n{INDENT}{ui.paint(title.upper(), ui.FIRE, bold=True)}")
78
+ for name, role in commands:
79
+ print(f"{INDENT} {ui.paint(f'{name:<{width}}', ui.STONE_LIGHT, bold=True)}{role}")
80
+ print(f"\n{INDENT}{ui.paint('EXEMPLES', ui.FIRE, bold=True)}")
81
+ for example in examples:
82
+ print(f"{INDENT} {ui.paint('$', ui.STONE_DARK)} {example}")
83
+ print(
84
+ f"\n{INDENT}{ui.paint('kiln <commande> --help', bold=True)}"
85
+ f"{ui.paint(f' pour les options · {REPO_URL}', ui.STONE)}\n"
86
+ )
87
+
88
+
89
+ def render_logo() -> list[str]:
90
+ def cell(top: str, bottom: str) -> str:
91
+ if top == EMPTY and bottom == EMPTY:
92
+ return " "
93
+ if bottom == EMPTY:
94
+ return f"{ui.foreground(LOGO_COLORS[top])}{UPPER_HALF}{ui.RESET}"
95
+ if top == EMPTY:
96
+ return f"{ui.foreground(LOGO_COLORS[bottom])}{LOWER_HALF}{ui.RESET}"
97
+ if top == bottom:
98
+ return f"{ui.foreground(LOGO_COLORS[top])}{FULL_BLOCK}{ui.RESET}"
99
+ colors = ui.foreground(LOGO_COLORS[top]) + ui.background(LOGO_COLORS[bottom])
100
+ return f"{colors}{UPPER_HALF}{ui.RESET}"
101
+
102
+ return [
103
+ "".join(cell(top, bottom) for top, bottom in zip(top_row, bottom_row, strict=True))
104
+ for top_row, bottom_row in zip(LOGO[0::2], LOGO[1::2], strict=True)
105
+ ]
106
+
107
+
108
+ def _project_summary(root: Path) -> str:
109
+ """Nom du projet, envergure et frameworks : `annski · flame · django + nuxt, podman`."""
110
+ try:
111
+ answers = answers_if_any(root)
112
+ stack = load_stack(root)
113
+ except KilnError, OSError, ValueError:
114
+ return root.name
115
+ frameworks = [name for name in (stack.backend, stack.frontend) if name != NO_FRAMEWORK]
116
+ pile = " + ".join(frameworks) + (", podman" if stack.has_podman else "")
117
+ parts = [str(answers.get("nom", root.name)), str(answers.get("envergure", "")), pile]
118
+ return " · ".join(part for part in parts if part)
119
+
120
+
121
+ def _home_relative(path: Path) -> str:
122
+ try:
123
+ return f"~/{path.relative_to(Path.home())}"
124
+ except ValueError:
125
+ return str(path)
@@ -0,0 +1 @@
1
+ """Moteur de charte : vérifie un repo contre les règles de son charte.toml."""
kiln/charte/charte.py ADDED
@@ -0,0 +1,160 @@
1
+ #!/usr/bin/env python3
2
+ """charte — vérifie un repo contre les règles de sa charte (CLAUDE.md), décrites dans le
3
+ `charte.toml` de sa racine. Installée avec kiln, à côté de la commande `kiln`.
4
+
5
+ Une charte que rien n'exécute décrit un projet qui n'existe pas. Deux régimes, parce qu'un
6
+ contrôle qui échoue sur 47 fichiers dès le premier jour finit désactivé :
7
+
8
+ - **interdiction** (`[interdictions.<nom>]`) : la règle est respectée partout, toute
9
+ réintroduction échoue ;
10
+ - **cliquet** (`[cliquets.<nom>]`, `budget`) : la règle a un passif, le nombre courant devient
11
+ un plafond. Le contrôle échoue si le passif augmente, et signale s'il diminue — le budget
12
+ doit alors être abaissé dans charte.toml, ce qui rend la dette impossible à reprendre.
13
+ Un **plancher** (`[planchers.<nom>]`, `plancher`) est le cliquet inverse.
14
+
15
+ Le nom d'une section est celui de la règle (voir interdictions.py et cliquets.py) ; une clé
16
+ `regle` permet d'en déclarer plusieurs du même type. `regles_locales` : modules Python du
17
+ repo, chacun avec une fonction `verifier(depot, params) -> list[str]`, pour ses règles propres.
18
+
19
+ Usage : charte [verifier] règles de la charte (défaut)
20
+ charte types typage mypy en cliquet ([types])
21
+ charte couverture planchers de couverture du backend ([couverture])
22
+ charte --detail valeur de chaque cliquet et plancher, sans juger
23
+ Sortie non nulle si une règle est enfreinte.
24
+ """
25
+
26
+ from __future__ import annotations
27
+
28
+ import importlib.util
29
+ import subprocess
30
+ import sys
31
+ import tomllib
32
+ from collections.abc import Callable
33
+ from pathlib import Path
34
+
35
+ from kiln.charte.cliquets import COMPTEURS
36
+ from kiln.charte.interdictions import REGLES
37
+ from kiln.charte.moteur import Depot, Params, echec
38
+ from kiln.charte.outils_python import couverture, types
39
+
40
+ # Règle propre au repo : `verifier(depot, params)` renvoie ses échecs.
41
+ RegleLocale = Callable[[Depot, Params], list[str]]
42
+
43
+ FIN_RAPPORT = "Règles de CLAUDE.md non vérifiées"
44
+
45
+
46
+ def charger() -> Depot:
47
+ racine = subprocess.run(
48
+ ["git", "rev-parse", "--show-toplevel"], capture_output=True, text=True
49
+ ).stdout.strip()
50
+ config = Path(racine or ".") / "charte.toml"
51
+ if not config.exists():
52
+ sys.exit(f"charte : pas de charte.toml à la racine du repo ({config.parent}).")
53
+ return Depot(config.parent, tomllib.loads(config.read_text("utf-8")))
54
+
55
+
56
+ def _regles_locales(depot: Depot) -> list[RegleLocale]:
57
+ fonctions: list[RegleLocale] = []
58
+ for chemin in depot.config.get("regles_locales", []):
59
+ spec = importlib.util.spec_from_file_location(Path(chemin).stem, depot.racine / chemin)
60
+ if spec is None or spec.loader is None:
61
+ raise SystemExit(f"charte : règle locale illisible : {chemin}")
62
+ module = importlib.util.module_from_spec(spec)
63
+ spec.loader.exec_module(module)
64
+ fonctions.append(module.verifier)
65
+ return fonctions
66
+
67
+
68
+ def _regles_non_verifiees(depot: Depot) -> None:
69
+ fichier = depot.config.get("regles_non_verifiees")
70
+ if not fichier:
71
+ return
72
+ regles = [
73
+ r
74
+ for r in (depot.racine / fichier).read_text("utf-8").splitlines()
75
+ if r and not r.startswith("#")
76
+ ]
77
+ print(f"\n{FIN_RAPPORT} par ce script (à appliquer ou à amender) :")
78
+ print("\n".join(f" - {r}" for r in regles))
79
+
80
+
81
+ def mesures(depot: Depot, section: str) -> list[tuple[str, Params, int, list[str]]]:
82
+ sortie = []
83
+ for nom, p in depot.config.get(section, {}).items():
84
+ valeur, detail = COMPTEURS[p.get("regle", nom)](depot, p)
85
+ sortie.append((nom, p, valeur, detail))
86
+ return sortie
87
+
88
+
89
+ def verifier(depot: Depot) -> int:
90
+ problemes: list[str] = []
91
+ for nom, p in depot.config.get("interdictions", {}).items():
92
+ problemes.extend(REGLES[p.get("regle", nom)](depot, p))
93
+ for fonction in _regles_locales(depot):
94
+ problemes.extend(fonction(depot, {}))
95
+
96
+ a_resserrer = []
97
+ for nom, p, valeur, detail in mesures(depot, "cliquets"):
98
+ budget = p["budget"]
99
+ if valeur > budget:
100
+ message = (
101
+ f"{p.get('message', nom)} : {valeur} infractions, plafond {budget}. "
102
+ f"{valeur - budget} de plus qu'au dernier abaissement."
103
+ )
104
+ problemes.append(echec(p.get("id", "CLIQUET"), message, detail))
105
+ elif valeur < budget:
106
+ a_resserrer.append(
107
+ f" {nom} : {valeur} < {budget} — abaisser `budget` de [cliquets.{nom}] "
108
+ f"à {valeur} (charte.toml)."
109
+ )
110
+ for nom, p, valeur, detail in mesures(depot, "planchers"):
111
+ plancher = p["plancher"]
112
+ if valeur < plancher:
113
+ message = (
114
+ f"{p.get('message', nom)} : {valeur}, plancher {plancher}. La valeur a baissé."
115
+ )
116
+ problemes.append(echec(p.get("id", "PLANCHER"), message, detail))
117
+ elif valeur > plancher:
118
+ a_resserrer.append(
119
+ f" {nom} : {valeur} > {plancher} — relever `plancher` de [planchers.{nom}] "
120
+ f"à {valeur} (charte.toml)."
121
+ )
122
+
123
+ if a_resserrer:
124
+ print("Dette réduite, budgets à resserrer (le contrôle reste vert) :")
125
+ print("\n".join(a_resserrer))
126
+ print()
127
+ if problemes:
128
+ print("Violations de CLAUDE.md :\n")
129
+ print("\n\n".join(problemes))
130
+ print("\nLa charte est la norme du dépôt. Corriger, ou l'amender explicitement.")
131
+ _regles_non_verifiees(depot)
132
+ return 1
133
+ print("Règles contrôlées de CLAUDE.md : aucune violation, aucun cliquet dépassé.")
134
+ _regles_non_verifiees(depot)
135
+ return 0
136
+
137
+
138
+ def detail(depot: Depot) -> int:
139
+ for section in ("cliquets", "planchers"):
140
+ for nom, p, valeur, _ in mesures(depot, section):
141
+ print(f"{section}.{nom} = {valeur} (seuil {p.get('budget', p.get('plancher'))})")
142
+ return 0
143
+
144
+
145
+ def main() -> int:
146
+ commande = sys.argv[1] if len(sys.argv) > 1 else "verifier"
147
+ depot = charger()
148
+ actions = {"verifier": verifier, "types": types, "couverture": couverture, "--detail": detail}
149
+ if commande not in actions:
150
+ sys.exit(__doc__)
151
+ return actions[commande](depot)
152
+
153
+
154
+ def lancer() -> None:
155
+ """Point d'entrée de la commande `charte` installée avec kiln."""
156
+ sys.exit(main())
157
+
158
+
159
+ if __name__ == "__main__":
160
+ lancer()
@@ -0,0 +1,108 @@
1
+ """Compteurs des cliquets (`[cliquets.<nom>]`, plafond `budget` qui ne fait que baisser) et des
2
+ planchers (`[planchers.<nom>]`, valeur `plancher` qui ne fait que monter).
3
+
4
+ Chaque compteur reçoit le dépôt et ses paramètres et renvoie (valeur, détail).
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import ast
10
+ import re
11
+
12
+ from kiln.charte.moteur import Depot, Params, hors_tests
13
+
14
+
15
+ def taille_fichier(depot: Depot, p: Params) -> tuple[int, list[str]]:
16
+ """`max_lignes` (300) lignes au plus par fichier ; les fichiers engendrés ne comptent pas."""
17
+ suffixes = set(p.get("suffixes", [".py", ".ts", ".vue", ".css", ".mjs"]))
18
+ plafond = p.get("max_lignes", 300)
19
+ fautifs = []
20
+ for f in depot.suivis(p["fichiers"]):
21
+ if f.suffix not in suffixes or depot.engendre(f):
22
+ continue
23
+ n = len(f.read_text("utf-8").splitlines())
24
+ if n > plafond:
25
+ fautifs.append(f"{n:5d} {depot.rel(f)}")
26
+ return len(fautifs), sorted(fautifs, reverse=True)
27
+
28
+
29
+ def routes_longues(depot: Depot, p: Params) -> tuple[int, list[str]]:
30
+ """Vues de plus de `max_lignes` (15) lignes, repérées par leur décorateur (`decorateur`,
31
+ regex ; par défaut celui de django-ninja)."""
32
+ decorateur = re.compile(p.get("decorateur", r"\s*@(api|router)\.(get|post|put|patch|delete)"))
33
+ plafond = p.get("max_lignes", 15)
34
+ fautifs = []
35
+ for f in depot.suivis(p.get("fichiers", ["backend/*/api/*.py", "backend/*/api.py"])):
36
+ lignes = f.read_text("utf-8").splitlines()
37
+ for i, ligne in enumerate(lignes):
38
+ if not decorateur.match(ligne):
39
+ continue
40
+ j = i
41
+ while j < len(lignes) and not re.match(r"\s*def ", lignes[j]):
42
+ j += 1
43
+ if j >= len(lignes):
44
+ continue
45
+ retrait = len(lignes[j]) - len(lignes[j].lstrip())
46
+ k = j + 1
47
+ while k < len(lignes) and not (
48
+ lignes[k].strip() and len(lignes[k]) - len(lignes[k].lstrip()) <= retrait
49
+ ):
50
+ k += 1
51
+ if k - j > plafond:
52
+ fautifs.append(f"{k - j:5d} {depot.rel(f)}:{j + 1} {lignes[j].strip()[:60]}")
53
+ return len(fautifs), sorted(fautifs, reverse=True)
54
+
55
+
56
+ def verrous_django(depot: Depot, p: Params) -> tuple[int, list[str]]:
57
+ """`select_for_update` sans `nowait`/`skip_locked` ni justification « # Verrou bloquant : »
58
+ dans les 8 lignes au-dessus. Analyse syntaxique : une docstring n'est pas un verrou."""
59
+ fautifs = []
60
+ for f in hors_tests(depot.suivis(p.get("fichiers", ["backend/*.py", "backend/**/*.py"]))):
61
+ texte = f.read_text("utf-8")
62
+ lignes = texte.splitlines()
63
+ for n in ast.walk(ast.parse(texte)):
64
+ if not (isinstance(n, ast.Call) and getattr(n.func, "attr", "") == "select_for_update"):
65
+ continue
66
+ garde = any(k.arg in {"nowait", "skip_locked"} for k in n.keywords)
67
+ au_dessus = "\n".join(lignes[max(0, n.lineno - 9) : n.lineno])
68
+ if not garde and not re.search(r"#\s*Verrou bloquant\s*:", au_dessus):
69
+ fautifs.append(f"{depot.rel(f)}:{n.lineno}")
70
+ return len(fautifs), fautifs
71
+
72
+
73
+ def occurrences(depot: Depot, p: Params) -> tuple[int, list[str]]:
74
+ """Nombre d'occurrences d'un texte (`motif`, littéral) dans des fichiers : par exemple
75
+ `timezone.now()` dans les tests, où l'horloge doit être figée."""
76
+ fautifs = []
77
+ for f in depot.suivis(p["fichiers"]):
78
+ n = f.read_text("utf-8").count(p["motif"])
79
+ if n:
80
+ fautifs.append(f"{n:4d} {depot.rel(f)}")
81
+ return sum(int(o.split()[0]) for o in fautifs), sorted(fautifs, reverse=True)
82
+
83
+
84
+ def densite_tests(depot: Depot, p: Params) -> tuple[int, list[str]]:
85
+ """Lignes de test pour mille lignes de code (fichiers engendrés exclus). Les fichiers de
86
+ test finissent par un des `suffixes_tests`."""
87
+ suffixes = set(p.get("suffixes", [".ts", ".vue", ".mjs"]))
88
+ tests_fin = tuple(p.get("suffixes_tests", [".spec.ts", ".nuxt.spec.ts"]))
89
+ code = tests = 0
90
+ for f in depot.suivis(p["fichiers"]):
91
+ if f.suffix not in suffixes or depot.engendre(f):
92
+ continue
93
+ n = len(f.read_text("utf-8").splitlines())
94
+ if f.name.endswith(tests_fin):
95
+ tests += n
96
+ else:
97
+ code += n
98
+ ratio = round(1000 * tests / code) if code else 0
99
+ return ratio, [f"{tests} lignes de test pour {code} lignes de code ({ratio}/1000)"]
100
+
101
+
102
+ COMPTEURS = {
103
+ "taille-fichier": taille_fichier,
104
+ "routes-longues": routes_longues,
105
+ "verrous-django": verrous_django,
106
+ "occurrences": occurrences,
107
+ "densite-tests": densite_tests,
108
+ }