okflint 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.
- okflint/__init__.py +10 -0
- okflint/__main__.py +6 -0
- okflint/audit.py +409 -0
- okflint/cli.py +165 -0
- okflint/manifest.py +369 -0
- okflint/py.typed +0 -0
- okflint/scanner.py +426 -0
- okflint/validate.py +714 -0
- okflint-0.1.0.dist-info/METADATA +261 -0
- okflint-0.1.0.dist-info/RECORD +13 -0
- okflint-0.1.0.dist-info/WHEEL +4 -0
- okflint-0.1.0.dist-info/entry_points.txt +2 -0
- okflint-0.1.0.dist-info/licenses/LICENSE +21 -0
okflint/__init__.py
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
"""okflint — linter de conformité pour bases documentaires OKF.
|
|
2
|
+
|
|
3
|
+
Outil de contrôle qualité déterministe (façon Ruff/mypy) pour vérifier
|
|
4
|
+
la conformité de documents Markdown au standard Open Knowledge Format.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
__project_name__ = "okflint"
|
|
8
|
+
__author__ = "Matthieu Daviaud"
|
|
9
|
+
__email__ = "matthieu.daviaud@gmail.com"
|
|
10
|
+
__version__ = "0.1.0"
|
okflint/__main__.py
ADDED
okflint/audit.py
ADDED
|
@@ -0,0 +1,409 @@
|
|
|
1
|
+
"""Audit d'un bundle OKF Obsidian — inventaire et diagnostic de conformité."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import dataclasses
|
|
6
|
+
import datetime
|
|
7
|
+
import re
|
|
8
|
+
import unicodedata
|
|
9
|
+
from dataclasses import dataclass
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
from typing import Any, Literal
|
|
12
|
+
|
|
13
|
+
from beartype import beartype
|
|
14
|
+
|
|
15
|
+
from okflint.scanner import (
|
|
16
|
+
MarkdownLink,
|
|
17
|
+
WikiLink,
|
|
18
|
+
blank_code_spans,
|
|
19
|
+
build_file_index,
|
|
20
|
+
extract_markdown_links,
|
|
21
|
+
extract_wikilinks,
|
|
22
|
+
parse_frontmatter,
|
|
23
|
+
)
|
|
24
|
+
|
|
25
|
+
OkfStatus = Literal["conformant", "partial", "non_conformant"]
|
|
26
|
+
|
|
27
|
+
# Noms réservés OKF v0.1 (ne sont pas des concepts)
|
|
28
|
+
RESERVED_NAMES: set[str] = {"index.md", "log.md"}
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
_HEADER_RE = re.compile(r"^(#{1,2})\s+(.+)$")
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
@dataclass
|
|
35
|
+
class Header:
|
|
36
|
+
"""Représente un titre H1 ou H2 dans un fichier."""
|
|
37
|
+
|
|
38
|
+
level: int
|
|
39
|
+
text: str
|
|
40
|
+
line: int
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
@dataclass
|
|
44
|
+
class FileReport:
|
|
45
|
+
"""Rapport d'analyse d'un fichier .md du bundle."""
|
|
46
|
+
|
|
47
|
+
path: str
|
|
48
|
+
depth: int
|
|
49
|
+
lines: int
|
|
50
|
+
chars: int
|
|
51
|
+
is_reserved: bool
|
|
52
|
+
okf_status: OkfStatus
|
|
53
|
+
frontmatter: dict[str, Any] | None
|
|
54
|
+
wikilinks: list[WikiLink]
|
|
55
|
+
markdown_links: list[MarkdownLink]
|
|
56
|
+
split_candidate: bool
|
|
57
|
+
split_reason: str | None # "multiple_h1" | "homogeneous_h2_list"
|
|
58
|
+
split_entity_count: int | None
|
|
59
|
+
headers: list[Header]
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
# Mots-clés de sections structurelles (ADR, Runbook, Journal, meta-docs)
|
|
63
|
+
# Un H2 contenant l'un de ces mots/phrases est une section d'un concept unique
|
|
64
|
+
_STRUCTURAL_H2_KEYWORDS: frozenset[str] = frozenset(
|
|
65
|
+
{
|
|
66
|
+
# ADR / décision
|
|
67
|
+
"contexte",
|
|
68
|
+
"options",
|
|
69
|
+
"considérées",
|
|
70
|
+
"décision",
|
|
71
|
+
"conséquences",
|
|
72
|
+
"alternatives",
|
|
73
|
+
"annexes",
|
|
74
|
+
# Runbook / procédure
|
|
75
|
+
"prérequis",
|
|
76
|
+
"dépendances",
|
|
77
|
+
"installation",
|
|
78
|
+
"configuration",
|
|
79
|
+
"utilisation",
|
|
80
|
+
"références",
|
|
81
|
+
"résultats",
|
|
82
|
+
"actions",
|
|
83
|
+
"réalisées",
|
|
84
|
+
"pièges",
|
|
85
|
+
"rencontrés",
|
|
86
|
+
"reste",
|
|
87
|
+
"suite",
|
|
88
|
+
"leçons",
|
|
89
|
+
"bilan",
|
|
90
|
+
"diagnostic",
|
|
91
|
+
"symptômes",
|
|
92
|
+
"liens",
|
|
93
|
+
"troubleshooting",
|
|
94
|
+
"rollback",
|
|
95
|
+
"vérification",
|
|
96
|
+
"historique",
|
|
97
|
+
"maintenance",
|
|
98
|
+
# Architecture / meta-document
|
|
99
|
+
"architecture",
|
|
100
|
+
"navigation",
|
|
101
|
+
"objectifs",
|
|
102
|
+
"inventaire",
|
|
103
|
+
# Statuts de projet (kanban, TODO)
|
|
104
|
+
"en cours",
|
|
105
|
+
"en attente",
|
|
106
|
+
"à venir",
|
|
107
|
+
"résolu",
|
|
108
|
+
"idées",
|
|
109
|
+
}
|
|
110
|
+
)
|
|
111
|
+
|
|
112
|
+
# Pattern de H2 séquentiels (procédures numérotées, étapes)
|
|
113
|
+
_SEQUENTIAL_H2_RE = re.compile(
|
|
114
|
+
r"^(?:\d+[\s.\-—]|[Éé]tape\s|Step\s|Partie\s|Part\s|Phase\s)",
|
|
115
|
+
re.IGNORECASE,
|
|
116
|
+
)
|
|
117
|
+
|
|
118
|
+
# Types frontmatter qui indiquent un document non-découpable
|
|
119
|
+
_NONSPLIT_TYPES: frozenset[str] = frozenset(
|
|
120
|
+
{
|
|
121
|
+
"journal",
|
|
122
|
+
"journalentry",
|
|
123
|
+
"runbook",
|
|
124
|
+
"procedure",
|
|
125
|
+
}
|
|
126
|
+
)
|
|
127
|
+
|
|
128
|
+
# H1 commençant par une date → journal de session (même sans type frontmatter)
|
|
129
|
+
_DATE_H1_RE = re.compile(r"^\d{4}-\d{2}-\d{2}")
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def _is_structural_h2(text: str) -> bool:
|
|
133
|
+
"""Indique si un titre H2 est une section structurelle (pas une entité listable).
|
|
134
|
+
|
|
135
|
+
Args:
|
|
136
|
+
text: Texte du titre H2.
|
|
137
|
+
|
|
138
|
+
Returns:
|
|
139
|
+
True si le H2 est une section de document (ADR, runbook, journal).
|
|
140
|
+
"""
|
|
141
|
+
# NFC pour neutraliser les variantes d'encodage des accents
|
|
142
|
+
lower = unicodedata.normalize("NFC", text).lower()
|
|
143
|
+
return any(kw in lower for kw in _STRUCTURAL_H2_KEYWORDS)
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def _is_nonsplit_type(frontmatter: dict[str, Any] | None) -> bool:
|
|
147
|
+
"""Indique si le type frontmatter exclut le fichier du découpage.
|
|
148
|
+
|
|
149
|
+
Args:
|
|
150
|
+
frontmatter: Frontmatter parsé, ou None si absent.
|
|
151
|
+
|
|
152
|
+
Returns:
|
|
153
|
+
True si le type indique un document séquentiel non-découpable.
|
|
154
|
+
"""
|
|
155
|
+
if frontmatter is None:
|
|
156
|
+
return False
|
|
157
|
+
raw = str(frontmatter.get("type", "")).lower().replace("-", "").replace("_", "")
|
|
158
|
+
return raw in _NONSPLIT_TYPES
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
def _is_sequential_h2(text: str) -> bool:
|
|
162
|
+
"""Indique si un titre H2 est un élément de liste séquentielle (étape, partie).
|
|
163
|
+
|
|
164
|
+
Args:
|
|
165
|
+
text: Texte du titre H2.
|
|
166
|
+
|
|
167
|
+
Returns:
|
|
168
|
+
True si le H2 est une étape numérotée ou nommée séquentiellement.
|
|
169
|
+
"""
|
|
170
|
+
normalized = unicodedata.normalize("NFC", text)
|
|
171
|
+
return bool(_SEQUENTIAL_H2_RE.match(normalized))
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
def _is_session_journal(headers: list[Header]) -> bool:
|
|
175
|
+
"""Indique si le fichier est un journal de session (H1 commence par une date).
|
|
176
|
+
|
|
177
|
+
Détecte les journaux sans type frontmatter via leur H1 daté (YYYY-MM-DD...).
|
|
178
|
+
|
|
179
|
+
Args:
|
|
180
|
+
headers: Liste de headers extraits du fichier.
|
|
181
|
+
|
|
182
|
+
Returns:
|
|
183
|
+
True si le fichier est un journal de session.
|
|
184
|
+
"""
|
|
185
|
+
h1s = [h for h in headers if h.level == 1]
|
|
186
|
+
return bool(h1s) and all(_DATE_H1_RE.match(h.text) for h in h1s)
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
def _evaluate_split(
|
|
190
|
+
headers: list[Header],
|
|
191
|
+
frontmatter: dict[str, Any] | None,
|
|
192
|
+
) -> tuple[bool, str | None, int | None]:
|
|
193
|
+
"""Détermine si un fichier est candidat au découpage selon des critères sémantiques.
|
|
194
|
+
|
|
195
|
+
Critères de déclenchement (dans l'ordre) :
|
|
196
|
+
- multiple_h1 : ≥ 2 H1 avec des textes distincts
|
|
197
|
+
- homogeneous_h2_list : ≥ 4 H2 dont < 2 sont structurels et < 50% séquentiels
|
|
198
|
+
|
|
199
|
+
Exclusions préalables :
|
|
200
|
+
- Type frontmatter journal/runbook/procedure
|
|
201
|
+
- Journal de session détecté par H1 daté
|
|
202
|
+
- H1 dupliqués (même texte, anomalie de copier-coller)
|
|
203
|
+
|
|
204
|
+
Args:
|
|
205
|
+
headers: Liste des headers extraits du fichier.
|
|
206
|
+
frontmatter: Frontmatter parsé, ou None si absent.
|
|
207
|
+
|
|
208
|
+
Returns:
|
|
209
|
+
Tuple (split_candidate, split_reason, split_entity_count).
|
|
210
|
+
"""
|
|
211
|
+
if _is_nonsplit_type(frontmatter):
|
|
212
|
+
return False, None, None
|
|
213
|
+
|
|
214
|
+
if _is_session_journal(headers):
|
|
215
|
+
return False, None, None
|
|
216
|
+
|
|
217
|
+
h1s = [h for h in headers if h.level == 1]
|
|
218
|
+
h2s = [h for h in headers if h.level == 2]
|
|
219
|
+
|
|
220
|
+
if len(h1s) >= 2:
|
|
221
|
+
if len({h.text for h in h1s}) == 1:
|
|
222
|
+
# H1 identiques : anomalie de copier-coller, pas un découpage
|
|
223
|
+
return False, None, None
|
|
224
|
+
return True, "multiple_h1", len(h1s)
|
|
225
|
+
|
|
226
|
+
if len(h2s) >= 4:
|
|
227
|
+
structural_count = sum(1 for h in h2s if _is_structural_h2(h.text))
|
|
228
|
+
sequential_count = sum(1 for h in h2s if _is_sequential_h2(h.text))
|
|
229
|
+
if structural_count < 2 and sequential_count * 2 < len(h2s):
|
|
230
|
+
return True, "homogeneous_h2_list", len(h2s)
|
|
231
|
+
|
|
232
|
+
return False, None, None
|
|
233
|
+
|
|
234
|
+
|
|
235
|
+
@beartype
|
|
236
|
+
def get_okf_status(frontmatter: dict[str, Any] | None) -> OkfStatus:
|
|
237
|
+
"""Détermine le statut OKF d'un concept selon son frontmatter.
|
|
238
|
+
|
|
239
|
+
Args:
|
|
240
|
+
frontmatter: Frontmatter parsé, ou None si absent.
|
|
241
|
+
|
|
242
|
+
Returns:
|
|
243
|
+
'conformant' | 'partial' | 'non_conformant'
|
|
244
|
+
"""
|
|
245
|
+
if frontmatter is None:
|
|
246
|
+
return "non_conformant"
|
|
247
|
+
if frontmatter.get("type"):
|
|
248
|
+
return "conformant"
|
|
249
|
+
return "partial"
|
|
250
|
+
|
|
251
|
+
|
|
252
|
+
@beartype
|
|
253
|
+
def extract_headers(content: str) -> list[Header]:
|
|
254
|
+
"""Extrait les titres H1 et H2 avec leur numéro de ligne dans le body.
|
|
255
|
+
|
|
256
|
+
Doit recevoir un contenu pré-blanqué via blank_code_spans pour ignorer
|
|
257
|
+
les `#` dans les blocs de code.
|
|
258
|
+
|
|
259
|
+
Args:
|
|
260
|
+
content: Corps du fichier avec blocs de code masqués.
|
|
261
|
+
|
|
262
|
+
Returns:
|
|
263
|
+
Liste de Header (niveaux 1 et 2 uniquement).
|
|
264
|
+
"""
|
|
265
|
+
headers: list[Header] = []
|
|
266
|
+
for i, line in enumerate(content.splitlines(), start=1):
|
|
267
|
+
m = _HEADER_RE.match(line)
|
|
268
|
+
if m:
|
|
269
|
+
headers.append(
|
|
270
|
+
Header(level=len(m.group(1)), text=m.group(2).strip(), line=i)
|
|
271
|
+
)
|
|
272
|
+
return headers
|
|
273
|
+
|
|
274
|
+
|
|
275
|
+
@beartype
|
|
276
|
+
def analyze_file(
|
|
277
|
+
file_path: Path,
|
|
278
|
+
bundle_path: Path,
|
|
279
|
+
vault_index: dict[str, list[str]],
|
|
280
|
+
) -> FileReport:
|
|
281
|
+
"""Analyse complète d'un fichier .md du bundle.
|
|
282
|
+
|
|
283
|
+
Args:
|
|
284
|
+
file_path: Chemin absolu du fichier.
|
|
285
|
+
bundle_path: Racine du bundle.
|
|
286
|
+
vault_index: Index vault pour résolution des wikilinks.
|
|
287
|
+
|
|
288
|
+
Returns:
|
|
289
|
+
FileReport avec tous les champs remplis.
|
|
290
|
+
"""
|
|
291
|
+
rel_path = file_path.relative_to(bundle_path).as_posix()
|
|
292
|
+
depth = len(file_path.relative_to(bundle_path).parts) - 1
|
|
293
|
+
is_reserved = file_path.name.lower() in RESERVED_NAMES
|
|
294
|
+
|
|
295
|
+
content = file_path.read_text(encoding="utf-8")
|
|
296
|
+
lines = len(content.splitlines())
|
|
297
|
+
chars = len(content)
|
|
298
|
+
|
|
299
|
+
frontmatter, body = parse_frontmatter(content)
|
|
300
|
+
okf_status = get_okf_status(frontmatter)
|
|
301
|
+
|
|
302
|
+
safe_body = blank_code_spans(body)
|
|
303
|
+
wikilinks = extract_wikilinks(safe_body, vault_index)
|
|
304
|
+
markdown_links = extract_markdown_links(safe_body, file_path, bundle_path)
|
|
305
|
+
|
|
306
|
+
all_headers = extract_headers(safe_body)
|
|
307
|
+
split_candidate, split_reason, split_entity_count = _evaluate_split(
|
|
308
|
+
all_headers, frontmatter
|
|
309
|
+
)
|
|
310
|
+
headers = all_headers if split_candidate else []
|
|
311
|
+
|
|
312
|
+
return FileReport(
|
|
313
|
+
path=rel_path,
|
|
314
|
+
depth=depth,
|
|
315
|
+
lines=lines,
|
|
316
|
+
chars=chars,
|
|
317
|
+
is_reserved=is_reserved,
|
|
318
|
+
okf_status=okf_status,
|
|
319
|
+
frontmatter=frontmatter,
|
|
320
|
+
wikilinks=wikilinks,
|
|
321
|
+
markdown_links=markdown_links,
|
|
322
|
+
split_candidate=split_candidate,
|
|
323
|
+
split_reason=split_reason,
|
|
324
|
+
split_entity_count=split_entity_count,
|
|
325
|
+
headers=headers,
|
|
326
|
+
)
|
|
327
|
+
|
|
328
|
+
|
|
329
|
+
@beartype
|
|
330
|
+
def compute_stats(files: list[FileReport], vault_total: int) -> dict[str, Any]:
|
|
331
|
+
"""Agrège les statistiques globales du rapport.
|
|
332
|
+
|
|
333
|
+
Seuls les fichiers non réservés sont comptés dans by_okf_status.
|
|
334
|
+
|
|
335
|
+
Args:
|
|
336
|
+
files: Liste des rapports individuels.
|
|
337
|
+
vault_total: Nombre total de fichiers .md dans la vault entière.
|
|
338
|
+
|
|
339
|
+
Returns:
|
|
340
|
+
Dictionnaire de statistiques.
|
|
341
|
+
"""
|
|
342
|
+
concept_files = [f for f in files if not f.is_reserved]
|
|
343
|
+
|
|
344
|
+
by_status: dict[str, int] = {"conformant": 0, "partial": 0, "non_conformant": 0}
|
|
345
|
+
for f in concept_files:
|
|
346
|
+
by_status[f.okf_status] += 1
|
|
347
|
+
|
|
348
|
+
total_wikilinks = sum(len(f.wikilinks) for f in files)
|
|
349
|
+
broken_wikilinks = sum(1 for f in files for w in f.wikilinks if w.broken)
|
|
350
|
+
ambiguous_wikilinks = sum(1 for f in files for w in f.wikilinks if w.ambiguous)
|
|
351
|
+
total_md_links = sum(len(f.markdown_links) for f in files)
|
|
352
|
+
broken_md_links = sum(1 for f in files for ml in f.markdown_links if ml.broken)
|
|
353
|
+
split_candidates = sum(1 for f in files if f.split_candidate)
|
|
354
|
+
total_lines = sum(f.lines for f in files)
|
|
355
|
+
total_chars = sum(f.chars for f in files)
|
|
356
|
+
|
|
357
|
+
return {
|
|
358
|
+
"total_files": len(files),
|
|
359
|
+
"total_concept_files": len(concept_files),
|
|
360
|
+
"total_reserved_files": len(files) - len(concept_files),
|
|
361
|
+
"total_lines": total_lines,
|
|
362
|
+
"total_chars": total_chars,
|
|
363
|
+
"by_okf_status": by_status,
|
|
364
|
+
"total_wikilinks": total_wikilinks,
|
|
365
|
+
"broken_wikilinks": broken_wikilinks,
|
|
366
|
+
"ambiguous_wikilinks": ambiguous_wikilinks,
|
|
367
|
+
"total_markdown_links": total_md_links,
|
|
368
|
+
"broken_markdown_links": broken_md_links,
|
|
369
|
+
"split_candidates": split_candidates,
|
|
370
|
+
"vault_total_files": vault_total,
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
|
|
374
|
+
@beartype
|
|
375
|
+
def run_audit(bundle_path: Path, vault_path: Path) -> dict[str, Any]:
|
|
376
|
+
"""Orchestre l'audit complet d'un bundle OKF.
|
|
377
|
+
|
|
378
|
+
Args:
|
|
379
|
+
bundle_path: Racine du bundle à auditer.
|
|
380
|
+
vault_path: Racine de la vault Obsidian (pour l'index wikilinks).
|
|
381
|
+
|
|
382
|
+
Returns:
|
|
383
|
+
Rapport d'audit complet sérialisable en JSON.
|
|
384
|
+
"""
|
|
385
|
+
print(f"🔎 Indexation de la vault : {vault_path}")
|
|
386
|
+
vault_index = build_file_index([vault_path])
|
|
387
|
+
vault_total = sum(len(v) for v in vault_index.values())
|
|
388
|
+
print(f" {vault_total} fichiers .md indexés")
|
|
389
|
+
|
|
390
|
+
print(f"📦 Scan du bundle : {bundle_path}")
|
|
391
|
+
md_files = sorted(bundle_path.rglob("*.md"))
|
|
392
|
+
print(f" {len(md_files)} fichiers trouvés")
|
|
393
|
+
|
|
394
|
+
files: list[FileReport] = []
|
|
395
|
+
for md_file in md_files:
|
|
396
|
+
report = analyze_file(md_file, bundle_path, vault_index)
|
|
397
|
+
files.append(report)
|
|
398
|
+
|
|
399
|
+
stats = compute_stats(files, vault_total)
|
|
400
|
+
|
|
401
|
+
return {
|
|
402
|
+
"generated_at": datetime.datetime.now(datetime.UTC).strftime(
|
|
403
|
+
"%Y-%m-%dT%H:%M:%S"
|
|
404
|
+
),
|
|
405
|
+
"bundle_path": bundle_path.as_posix(),
|
|
406
|
+
"vault_path": vault_path.as_posix(),
|
|
407
|
+
"stats": stats,
|
|
408
|
+
"files": [dataclasses.asdict(f) for f in files],
|
|
409
|
+
}
|
okflint/cli.py
ADDED
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
"""Point d'entrée CLI unifié d'okflint.
|
|
2
|
+
|
|
3
|
+
Expose deux sous-commandes façon Ruff :
|
|
4
|
+
okflint audit — inventaire et diagnostic descriptif d'une base
|
|
5
|
+
okflint validate — gate normatif de conformité (exit 0/1)
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import argparse
|
|
11
|
+
import dataclasses
|
|
12
|
+
import json
|
|
13
|
+
import sys
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
|
|
16
|
+
from beartype import beartype
|
|
17
|
+
|
|
18
|
+
from okflint.audit import run_audit
|
|
19
|
+
from okflint.validate import ManifestError, run_validate
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def _cmd_audit(args: argparse.Namespace) -> int:
|
|
23
|
+
"""Exécute la sous-commande audit.
|
|
24
|
+
|
|
25
|
+
Args:
|
|
26
|
+
args: Namespace argparse (bundle, vault, apply).
|
|
27
|
+
|
|
28
|
+
Returns:
|
|
29
|
+
Code de sortie (toujours 0 : audit est descriptif).
|
|
30
|
+
"""
|
|
31
|
+
bundle_path = Path(args.bundle)
|
|
32
|
+
vault_path = Path(args.vault)
|
|
33
|
+
report = run_audit(bundle_path, vault_path)
|
|
34
|
+
stats = report["stats"]
|
|
35
|
+
n_concepts = stats["total_concept_files"]
|
|
36
|
+
print(f"Fichiers : {stats['total_files']} ({n_concepts} concepts)")
|
|
37
|
+
print(f"Statut OKF : {stats['by_okf_status']}")
|
|
38
|
+
wikilinks_broken = stats["broken_wikilinks"]
|
|
39
|
+
print(f"Wikilinks : {stats['total_wikilinks']} dont {wikilinks_broken} cassés")
|
|
40
|
+
md_broken = stats["broken_markdown_links"]
|
|
41
|
+
print(f"Liens MD : {stats['total_markdown_links']} dont {md_broken} cassés")
|
|
42
|
+
print(f"Candidats découpe : {stats['split_candidates']}")
|
|
43
|
+
|
|
44
|
+
if args.apply:
|
|
45
|
+
from datetime import date
|
|
46
|
+
|
|
47
|
+
outputs_dir = Path(".okflint")
|
|
48
|
+
outputs_dir.mkdir(exist_ok=True)
|
|
49
|
+
today = date.today().strftime("%Y-%m-%d")
|
|
50
|
+
v = 1
|
|
51
|
+
while (outputs_dir / f"{today}_audit_v{v}.json").exists():
|
|
52
|
+
v += 1
|
|
53
|
+
out = outputs_dir / f"{today}_audit_v{v}.json"
|
|
54
|
+
out.write_text(
|
|
55
|
+
json.dumps(report, indent=2, ensure_ascii=False), encoding="utf-8"
|
|
56
|
+
)
|
|
57
|
+
print(f"Rapport : {out}")
|
|
58
|
+
else:
|
|
59
|
+
print("(dry-run — relancer avec --apply pour écrire le rapport JSON)")
|
|
60
|
+
return 0
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def _cmd_validate(args: argparse.Namespace) -> int:
|
|
64
|
+
"""Exécute la sous-commande validate.
|
|
65
|
+
|
|
66
|
+
Args:
|
|
67
|
+
args: Namespace argparse (manifest, json_output, targets).
|
|
68
|
+
|
|
69
|
+
Returns:
|
|
70
|
+
Code de sortie (0 si conforme, 1 si au moins une erreur).
|
|
71
|
+
"""
|
|
72
|
+
manifest_path = Path(args.manifest)
|
|
73
|
+
targets = [Path(t) for t in args.targets]
|
|
74
|
+
try:
|
|
75
|
+
errors, code = run_validate(manifest_path, targets)
|
|
76
|
+
except ManifestError as exc:
|
|
77
|
+
print(f"Erreur manifeste : {exc}", file=sys.stderr)
|
|
78
|
+
return 2
|
|
79
|
+
|
|
80
|
+
if args.json_output:
|
|
81
|
+
payload = [dataclasses.asdict(e) for e in errors]
|
|
82
|
+
print(json.dumps(payload, indent=2, ensure_ascii=False))
|
|
83
|
+
else:
|
|
84
|
+
for e in errors:
|
|
85
|
+
icon = "❌" if e.severity == "error" else "⚠️"
|
|
86
|
+
print(f"{icon} [{e.code}] {e.file} — {e.message}")
|
|
87
|
+
if not errors:
|
|
88
|
+
print("✅ Tous les fichiers sont conformes OKF.")
|
|
89
|
+
else:
|
|
90
|
+
errs = sum(1 for e in errors if e.severity == "error")
|
|
91
|
+
warns = sum(1 for e in errors if e.severity == "warning")
|
|
92
|
+
print(f"\n{errs} erreur(s), {warns} avertissement(s).")
|
|
93
|
+
return code
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
@beartype
|
|
97
|
+
def build_parser() -> argparse.ArgumentParser:
|
|
98
|
+
"""Construit le parser argparse avec les sous-commandes.
|
|
99
|
+
|
|
100
|
+
Returns:
|
|
101
|
+
Le parser configuré.
|
|
102
|
+
"""
|
|
103
|
+
parser = argparse.ArgumentParser(
|
|
104
|
+
prog="okflint",
|
|
105
|
+
description="Linter de conformité pour bases documentaires OKF.",
|
|
106
|
+
)
|
|
107
|
+
subparsers = parser.add_subparsers(dest="command", required=True)
|
|
108
|
+
|
|
109
|
+
# -- audit ----------------------------------------------------------------
|
|
110
|
+
p_audit = subparsers.add_parser(
|
|
111
|
+
"audit", help="Inventaire et diagnostic descriptif d'une base."
|
|
112
|
+
)
|
|
113
|
+
p_audit.add_argument(
|
|
114
|
+
"--bundle",
|
|
115
|
+
required=True,
|
|
116
|
+
help="Racine du bundle à auditer.",
|
|
117
|
+
)
|
|
118
|
+
p_audit.add_argument(
|
|
119
|
+
"--vault",
|
|
120
|
+
required=True,
|
|
121
|
+
help="Racine de la vault (pour l'index de résolution des wikilinks).",
|
|
122
|
+
)
|
|
123
|
+
p_audit.add_argument(
|
|
124
|
+
"--apply",
|
|
125
|
+
action="store_true",
|
|
126
|
+
help="Écrit le rapport JSON dans .okflint/ (rapport daté auto-incrémenté).",
|
|
127
|
+
)
|
|
128
|
+
p_audit.set_defaults(func=_cmd_audit)
|
|
129
|
+
|
|
130
|
+
# -- validate -------------------------------------------------------------
|
|
131
|
+
p_validate = subparsers.add_parser(
|
|
132
|
+
"validate", help="Gate de conformité OKF (exit 0 si conforme, 1 sinon)."
|
|
133
|
+
)
|
|
134
|
+
p_validate.add_argument(
|
|
135
|
+
"--manifest",
|
|
136
|
+
default="okf-base.yaml",
|
|
137
|
+
help="Chemin du manifeste OKF (défaut : okf-base.yaml).",
|
|
138
|
+
)
|
|
139
|
+
p_validate.add_argument(
|
|
140
|
+
"--json",
|
|
141
|
+
dest="json_output",
|
|
142
|
+
action="store_true",
|
|
143
|
+
help="Sortie JSON (pour CI).",
|
|
144
|
+
)
|
|
145
|
+
p_validate.add_argument(
|
|
146
|
+
"targets",
|
|
147
|
+
nargs="+",
|
|
148
|
+
help="Fichiers ou dossiers à valider.",
|
|
149
|
+
)
|
|
150
|
+
p_validate.set_defaults(func=_cmd_validate)
|
|
151
|
+
|
|
152
|
+
return parser
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
@beartype
|
|
156
|
+
def main() -> None:
|
|
157
|
+
"""Point d'entrée console_scripts : okflint <command>."""
|
|
158
|
+
parser = build_parser()
|
|
159
|
+
args = parser.parse_args()
|
|
160
|
+
code: int = args.func(args)
|
|
161
|
+
sys.exit(code)
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
if __name__ == "__main__":
|
|
165
|
+
main()
|