Files
roadwave/scripts/generate-bdd-docs.py
jpgiannetti fd2b0f70c5 feat(rgpd): compléter documentation RGPD avec 12 nouvelles sections
Règles RGPD (docs/domains/_shared/rules/rgpd.md):
- Ajouter sections 13.11-13.22 (droits utilisateurs, mineurs, sécurité)
- Droit de rectification, opposition, limitation du traitement
- Gestion des mineurs: 13 ans minimum + consentement parental 13-15 ans
- Protection renforcée: RoadWave Kids pour < 13 ans
- Sécurité: chiffrement multi-niveaux, procédure breach 72h CNIL
- Politique de confidentialité avec versioning
- Sous-traitants, DPIA, délais de réponse

Entités (6 nouvelles):
- PARENTAL_CONSENTS + PARENTAL_CONTROLS (workflow 13-15 ans)
- PRIVACY_POLICY_VERSIONS + USER_POLICY_ACCEPTANCES
- ACCOUNT_DELETIONS (grace period 30j)
- BREACH_INCIDENTS + BREACH_AFFECTED_USERS
- USER_PROFILE_HISTORY (audit trail rectification)
- DATA_RETENTION_LOGS (purge 5 ans)

Diagrammes séquences (5 nouveaux):
- Consentement parental avec validation email
- Anonymisation GPS automatique après 24h
- Notification breach CNIL (procédure 72h)
- Export données asynchrone
- Suppression compte avec grace period

Cycles de vie (3 nouveaux + 1 enrichi):
- parental-consent-lifecycle.md
- breach-incident-lifecycle.md
- account-deletion-lifecycle.md
- user-account-lifecycle.md (ajout états mineurs, frozen)

Features BDD (4 nouvelles, 195 scénarios RGPD):
- minors-protection.feature (9 scénarios)
- data-security.feature (12 scénarios)
- privacy-policy.feature (8 scénarios)
- user-rights.feature (8 scénarios)

Infrastructure:
- Réorganiser docs générées: docs/bdd + output → generated/bdd + generated/pdf
- Mettre à jour mkdocs.yml, Makefile, scripts Python
- Ajouter /generated/ au .gitignore
2026-02-08 17:49:12 +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 / 'generated' / '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()