Files
roadwave/scripts/generate-bdd-docs.py
jpgiannetti 5e5fcf4714 refactor(docs): réorganiser la documentation selon principes DDD
Réorganise la documentation du projet selon les principes du Domain-Driven Design (DDD) pour améliorer la cohésion, la maintenabilité et l'alignement avec l'architecture modulaire du backend.

**Structure cible:**
```
docs/domains/
├── README.md (Context Map)
├── _shared/ (Core Domain)
├── recommendation/ (Supporting Subdomain)
├── content/ (Supporting Subdomain)
├── moderation/ (Supporting Subdomain)
├── advertising/ (Generic Subdomain)
├── premium/ (Generic Subdomain)
└── monetization/ (Generic Subdomain)
```

**Changements effectués:**

Phase 1: Création de l'arborescence des 7 bounded contexts
Phase 2: Déplacement des règles métier (01-19) vers domains/*/rules/
Phase 3: Déplacement des diagrammes d'entités vers domains/*/entities/
Phase 4: Déplacement des diagrammes flux/états/séquences vers domains/*/
Phase 5: Création des README.md pour chaque domaine
Phase 6: Déplacement des features Gherkin vers domains/*/features/
Phase 7: Création du Context Map (domains/README.md)
Phase 8: Mise à jour de mkdocs.yml pour la nouvelle navigation
Phase 9: Correction automatique des liens internes (script fix-markdown-links.sh)
Phase 10: Nettoyage de l'ancienne structure (regles-metier/, diagrammes/, features/)

**Configuration des tests:**
- Makefile: godog run docs/domains/*/features/
- scripts/generate-bdd-docs.py: features_dir → docs/domains

**Avantages:**
 Cohésion forte: toute la doc d'un domaine au même endroit
 Couplage faible: domaines indépendants, dépendances explicites
 Navigabilité améliorée: README par domaine = entrée claire
 Alignement code/docs: miroir de backend/internal/
 Onboarding facilité: exploration domaine par domaine
 Tests BDD intégrés: features au plus près des règles métier

Voir docs/REFACTOR-DDD.md pour le plan complet.
2026-02-07 17:15:02 +01:00

418 lines
14 KiB
Python

#!/usr/bin/env python3
"""
Script pour générer la documentation Markdown à partir des fichiers Gherkin (.feature)
Convertit les fichiers .feature en fichiers .md pour MkDocs Material
"""
import os
import re
from pathlib import Path
from typing import List, Dict, Optional, Tuple
from dataclasses import dataclass, field
@dataclass
class Step:
"""Représente une étape Gherkin"""
keyword: str # Étant donné, Quand, Alors, Et, Mais
text: str
table: List[List[str]] = field(default_factory=list)
@dataclass
class Scenario:
"""Représente un scénario ou plan de scénario"""
title: str
is_outline: bool
steps: List[Step] = field(default_factory=list)
examples: List[List[str]] = field(default_factory=list)
@dataclass
class Feature:
"""Représente une fonctionnalité complète"""
title: str
description: str = ""
context_steps: List[Step] = field(default_factory=list)
scenarios: List[Scenario] = field(default_factory=list)
def parse_table(lines: List[str], start_idx: int) -> Tuple[List[List[str]], int]:
"""Parse une table Gherkin et retourne (table, nouvel index)"""
table = []
idx = start_idx
while idx < len(lines) and lines[idx].strip().startswith('|'):
row = [cell.strip() for cell in lines[idx].strip().strip('|').split('|')]
table.append(row)
idx += 1
return table, idx
def parse_feature_content(content: str) -> Feature:
"""Parse le contenu d'un fichier .feature"""
# Supprimer la ligne de langage
content = re.sub(r'^# language: \w+\n', '', content)
lines = content.split('\n')
feature = Feature(title="")
i = 0
current_scenario: Optional[Scenario] = None
in_context = False
in_examples = False
while i < len(lines):
line = lines[i]
stripped = line.strip()
# Ligne vide
if not stripped:
i += 1
continue
# Fonctionnalité
if stripped.startswith('Fonctionnalité:'):
feature.title = stripped[len('Fonctionnalité:'):].strip()
i += 1
# Capturer la description (lignes indentées qui suivent)
desc_lines = []
while i < len(lines) and lines[i].startswith(' ') and not lines[i].strip().startswith(('Contexte:', 'Scénario:', 'Plan du Scénario:')):
desc_lines.append(lines[i].strip())
i += 1
feature.description = '\n'.join(desc_lines)
continue
# Contexte
if stripped == 'Contexte:':
in_context = True
in_examples = False
i += 1
continue
# Plan du Scénario
if stripped.startswith('Plan du Scénario:'):
if current_scenario:
feature.scenarios.append(current_scenario)
current_scenario = Scenario(
title=stripped[len('Plan du Scénario:'):].strip(),
is_outline=True
)
in_context = False
in_examples = False
i += 1
continue
# Scénario
if stripped.startswith('Scénario:'):
if current_scenario:
feature.scenarios.append(current_scenario)
current_scenario = Scenario(
title=stripped[len('Scénario:'):].strip(),
is_outline=False
)
in_context = False
in_examples = False
i += 1
continue
# Exemples (pour Plan du Scénario)
if stripped == 'Exemples:':
in_examples = True
i += 1
continue
# Table d'exemples
if in_examples and stripped.startswith('|') and current_scenario:
table, i = parse_table(lines, i)
current_scenario.examples = table
in_examples = False
continue
# Étapes Gherkin
keywords = ['Étant donné', 'Quand', 'Alors', 'Et', 'Mais']
matched_keyword = None
for kw in keywords:
if stripped.startswith(kw):
matched_keyword = kw
break
if matched_keyword:
step_text = stripped[len(matched_keyword):].strip()
step = Step(keyword=matched_keyword, text=step_text)
# Vérifier si une table suit
if i + 1 < len(lines) and lines[i + 1].strip().startswith('|'):
table, i = parse_table(lines, i + 1)
step.table = table
else:
i += 1
# Ajouter l'étape au bon endroit
if in_context:
feature.context_steps.append(step)
elif current_scenario:
current_scenario.steps.append(step)
continue
i += 1
# Ajouter le dernier scénario
if current_scenario:
feature.scenarios.append(current_scenario)
return feature
def format_table_markdown(table: List[List[str]], indent: str = "") -> str:
"""Formate une table en Markdown"""
if not table:
return ""
lines = []
# En-tête
lines.append(f"{indent}| " + " | ".join(table[0]) + " |")
# Séparateur
lines.append(f"{indent}|" + "|".join(["---" for _ in table[0]]) + "|")
# Lignes de données
for row in table[1:]:
lines.append(f"{indent}| " + " | ".join(row) + " |")
return '\n'.join(lines)
def get_keyword_color(keyword: str) -> str:
"""Retourne une couleur pour chaque type de mot-clé"""
colors = {
'Étant donné': '#2196F3', # Bleu
'Quand': '#FF9800', # Orange
'Alors': '#4CAF50', # Vert
'Et': '#9E9E9E', # Gris
'Mais': '#F44336' # Rouge
}
return colors.get(keyword, '#000000')
def format_step(step: Step, indent: str = "") -> str:
"""Formate une étape Gherkin avec couleur"""
color = get_keyword_color(step.keyword)
result = f'{indent}<span style="color: {color}">**{step.keyword}**</span> {step.text}'
if step.table:
result += "\n\n"
result += format_table_markdown(step.table, indent + " ")
result += "\n"
return result
def generate_markdown(feature: Feature) -> str:
"""Génère le contenu Markdown pour une fonctionnalité"""
lines = []
# Titre de la fonctionnalité
lines.append(f"# {feature.title}\n")
# Description en tant que user story (bloc quote formaté proprement)
if feature.description:
desc_lines = feature.description.split('\n')
for desc_line in desc_lines:
lines.append(f"> *{desc_line}*\n")
lines.append("\n")
# Statistiques
scenario_count = len(feature.scenarios)
outline_count = sum(1 for s in feature.scenarios if s.is_outline)
regular_count = scenario_count - outline_count
stats_parts = [f"**{scenario_count} scénario{'s' if scenario_count > 1 else ''}**"]
if outline_count > 0:
stats_parts.append(f" ({regular_count} standard{'s' if regular_count > 1 else ''}, {outline_count} plan{'s' if outline_count > 1 else ''})")
lines.append("".join(stats_parts) + "\n\n")
lines.append("---\n\n")
# Contexte
if feature.context_steps:
lines.append('!!! info "Contexte commun à tous les scénarios"\n\n')
for step in feature.context_steps:
lines.append(format_step(step, " ") + "\n")
lines.append("\n")
# Scénarios
for idx, scenario in enumerate(feature.scenarios, 1):
# Header h2 pour chaque scénario
if scenario.is_outline:
lines.append(f"## {idx}. 📋 Plan: {scenario.title}\n\n")
else:
lines.append(f"## {idx}. {scenario.title}\n\n")
# Regrouper les étapes par section (Given/When/Then)
current_section = None
for step in scenario.steps:
# Déterminer la section
if step.keyword == 'Étant donné':
section = 'given'
elif step.keyword == 'Quand':
section = 'when'
elif step.keyword == 'Alors':
section = 'then'
else:
section = current_section # Et/Mais gardent la section précédente
# Ajouter un séparateur visuel entre les sections
if section != current_section and current_section is not None:
lines.append("\n")
current_section = section
lines.append(format_step(step, "") + "\n")
# Exemples pour les plans de scénario
if scenario.is_outline and scenario.examples:
lines.append("\n**📊 Exemples de données:**\n\n")
lines.append(format_table_markdown(scenario.examples, "") + "\n")
lines.append("\n---\n\n")
return ''.join(lines)
def generate_index(features_dir: Path, output_dir: Path) -> None:
"""Génère un fichier index.md listant toutes les fonctionnalités"""
categories: Dict[str, List[Tuple[str, Path, int, int]]] = {}
for feature_file in features_dir.rglob('*.feature'):
category = feature_file.parent.name
relative_path = feature_file.relative_to(features_dir)
md_path = relative_path.with_suffix('.md')
# Extraire le titre et compter les scénarios
with open(feature_file, 'r', encoding='utf-8') as f:
content = f.read()
feature_match = re.search(r'^Fonctionnalité: (.+)$', content, re.MULTILINE)
title = feature_match.group(1) if feature_match else feature_file.stem
scenario_count = len(re.findall(r'^\s{2}Scénario:', content, re.MULTILINE))
outline_count = len(re.findall(r'^\s{2}Plan du Scénario:', content, re.MULTILINE))
if category not in categories:
categories[category] = []
categories[category].append((title, md_path, scenario_count, outline_count))
# Calculer les totaux
total_features = sum(len(cat) for cat in categories.values())
total_scenarios = sum(s + o for cat in categories.values() for _, _, s, o in cat)
# Générer l'index
index_lines = [
"# Tests BDD - Documentation des fonctionnalités\n\n",
"Cette documentation est générée automatiquement à partir des fichiers Gherkin (`.feature`).\n\n",
"## Vue d'ensemble\n\n",
f"| Métrique | Valeur |\n",
f"|----------|--------|\n",
f"| Fonctionnalités | **{total_features}** |\n",
f"| Scénarios | **{total_scenarios}** |\n",
f"| Domaines métier | **{len(categories)}** |\n\n",
"---\n\n"
]
# Icônes par catégorie
category_icons = {
'authentication': '🔐',
'recommendation': '🎯',
'interest-gauges': '📊',
'content-creation': '🎨',
'navigation': '🧭',
'publicites': '📢',
'radio-live': '📻',
'abonnements': '🔔',
'monetisation': '💰',
'premium': '',
'mode-offline': '📴',
'error-handling': '⚠️',
'rgpd-compliance': '🔒',
'moderation': '🛡️',
'partage': '🔗',
'profil': '👤',
'recherche': '🔍',
'audio-guides': '🎧',
}
for category in sorted(categories.keys()):
category_title = category.replace('-', ' ').replace('_', ' ').title()
icon = category_icons.get(category, '📁')
cat_scenarios = sum(s + o for _, _, s, o in categories[category])
index_lines.append(f"## {icon} {category_title}\n\n")
# Carte de la catégorie
index_lines.append(f"| Fonctionnalité | Scénarios |\n")
index_lines.append(f"|----------------|:---------:|\n")
for title, md_path, scenario_count, outline_count in sorted(categories[category]):
link = str(md_path).replace('\\', '/')
total = scenario_count + outline_count
index_lines.append(f"| [{title}]({link}) | {total} |\n")
index_lines.append(f"\n*{len(categories[category])} fonctionnalités • {cat_scenarios} scénarios*\n\n")
# Écrire l'index
index_file = output_dir / 'index.md'
with open(index_file, 'w', encoding='utf-8') as f:
f.writelines(index_lines)
print(f"✓ Index généré: {index_file}")
def main():
"""Point d'entrée principal"""
# Chemins
project_root = Path(__file__).parent.parent
features_dir = project_root / 'docs' / 'domains'
output_dir = project_root / 'docs' / 'bdd'
# Nettoyer le dossier de sortie
if output_dir.exists():
import shutil
shutil.rmtree(output_dir)
output_dir.mkdir(parents=True, exist_ok=True)
print(f"📁 Génération de la documentation BDD...")
print(f" Source: {features_dir}")
print(f" Destination: {output_dir}\n")
# Convertir tous les fichiers .feature
feature_files = list(features_dir.rglob('*.feature'))
count = 0
for feature_file in feature_files:
# Créer la structure de dossiers
relative_path = feature_file.relative_to(features_dir)
md_file = output_dir / relative_path.with_suffix('.md')
md_file.parent.mkdir(parents=True, exist_ok=True)
# Parser et convertir
with open(feature_file, 'r', encoding='utf-8') as f:
content = f.read()
feature = parse_feature_content(content)
markdown = generate_markdown(feature)
# Écrire le fichier
with open(md_file, 'w', encoding='utf-8') as f:
f.write(markdown)
count += 1
print(f"{relative_path}{md_file.relative_to(project_root)}")
print(f"\n{count} fichiers .feature convertis en Markdown")
# Générer l'index
print("\n📋 Génération de l'index...")
generate_index(features_dir, output_dir)
print(f"\n✅ Documentation BDD générée avec succès dans {output_dir}")
if __name__ == '__main__':
main()