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 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
@@ -0,0 +1,6 @@
1
+ """Permet l'exécution via `python -m okflint`."""
2
+
3
+ from okflint.cli import main
4
+
5
+ if __name__ == "__main__":
6
+ main()
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()