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 +25 -0
- kiln/__init__.py +1 -0
- kiln/backlog.py +168 -0
- kiln/banner.py +125 -0
- kiln/charte/__init__.py +1 -0
- kiln/charte/charte.py +160 -0
- kiln/charte/cliquets.py +108 -0
- kiln/charte/interdictions.py +328 -0
- kiln/charte/moteur.py +66 -0
- kiln/charte/outils_python.py +97 -0
- kiln/check.py +30 -0
- kiln/ci/__init__.py +5 -0
- kiln/ci/execution.py +161 -0
- kiln/ci/jobs.py +166 -0
- kiln/ci/runner.py +137 -0
- kiln/ci/services.py +115 -0
- kiln/ci/verdict.py +102 -0
- kiln/ci/workflow.py +95 -0
- kiln/cli.py +331 -0
- kiln/config.py +104 -0
- kiln/contract.py +34 -0
- kiln/deps.py +137 -0
- kiln/dev.py +98 -0
- kiln/doctor.py +291 -0
- kiln/fleet.py +303 -0
- kiln/github.py +100 -0
- kiln/install.py +130 -0
- kiln/new.py +96 -0
- kiln/poste.py +87 -0
- kiln/py.typed +0 -0
- kiln/release.py +153 -0
- kiln/rules.py +63 -0
- kiln/settings.py +108 -0
- kiln/shell.py +93 -0
- kiln/stack.py +211 -0
- kiln/statusline.py +97 -0
- kiln/template.py +21 -0
- kiln/ui.py +273 -0
- kiln/update.py +122 -0
- kiln/versions.py +184 -0
- kiln_cli-0.3.1.dist-info/METADATA +233 -0
- kiln_cli-0.3.1.dist-info/RECORD +45 -0
- kiln_cli-0.3.1.dist-info/WHEEL +4 -0
- kiln_cli-0.3.1.dist-info/entry_points.txt +4 -0
- kiln_cli-0.3.1.dist-info/licenses/LICENSE +21 -0
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)
|
kiln/charte/__init__.py
ADDED
|
@@ -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()
|
kiln/charte/cliquets.py
ADDED
|
@@ -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
|
+
}
|