blumeops/mise-tasks/docs-mikado

273 lines
8.3 KiB
Text
Raw Normal View History

#!/usr/bin/env -S uv run --script
# /// script
# requires-python = ">=3.12"
# dependencies = ["pyyaml>=6.0", "rich>=13.0.0", "typer>=0.15.0"]
# ///
#MISE description="View active Mikado dependency chains for C1/C2 changes"
#USAGE arg "[card]" help="Card stem to show chain for"
#USAGE flag "--all" help="Show all cards in full, including complete ones"
"""View active Mikado dependency chains for C1/C2 changes.
Scans all markdown files in docs/ for YAML frontmatter with ``status: active``
and ``requires`` fields, then builds and displays the Mikado dependency graph.
Usage:
mise run docs-mikado # list all active chains
mise run docs-mikado deploy-authentik # show chain for a card
mise run docs-mikado deploy-authentik --all # include complete cards in full
"""
import sys
from pathlib import Path
from typing import Annotated
import typer
import yaml
from rich.console import Console
from rich.markdown import Markdown
from rich.table import Table
DOCS_DIR = Path(__file__).parent.parent / "docs"
def extract_frontmatter(file_path: Path) -> dict | None:
"""Extract YAML frontmatter from a markdown file."""
content = file_path.read_text()
if not content.startswith("---"):
return None
end_idx = content.find("---", 3)
if end_idx == -1:
return None
frontmatter_text = content[3:end_idx].strip()
try:
return yaml.safe_load(frontmatter_text) or {}
except yaml.YAMLError:
return None
def build_graph() -> dict[str, dict]:
"""Build the dependency graph from all docs."""
cards: dict[str, dict] = {}
for md_file in sorted(DOCS_DIR.rglob("*.md")):
if "changelog.d" in md_file.parts:
continue
frontmatter = extract_frontmatter(md_file)
if frontmatter is None:
continue
stem = md_file.stem
cards[stem] = {
"path": md_file,
"title": frontmatter.get("title", stem),
"status": frontmatter.get("status"),
"requires": frontmatter.get("requires", []) or [],
"required_by": [],
}
# Compute inverse relationships
for stem, card in cards.items():
for req in card["requires"]:
if req in cards:
cards[req]["required_by"].append(stem)
return cards
def detect_cycles(cards: dict[str, dict]) -> list[list[str]]:
"""Detect circular dependencies in the graph. Returns list of cycles."""
cycles: list[list[str]] = []
visited: set[str] = set()
on_stack: set[str] = set()
def dfs(stem: str, path: list[str]) -> None:
if stem in on_stack:
cycle_start = path.index(stem)
cycles.append(path[cycle_start:] + [stem])
return
if stem in visited or stem not in cards:
return
visited.add(stem)
on_stack.add(stem)
path.append(stem)
for req in cards[stem]["requires"]:
dfs(req, path)
path.pop()
on_stack.discard(stem)
for stem in cards:
dfs(stem, [])
return cycles
def is_active(card: dict) -> bool:
"""Check if a card has status: active."""
return card.get("status") == "active"
def find_root_goals(cards: dict[str, dict]) -> list[str]:
"""Find active cards that aren't required by another active card."""
roots = []
for stem, card in cards.items():
if not is_active(card):
continue
# A root goal is not required by any other active card
has_active_parent = any(
is_active(cards[rb]) for rb in card["required_by"] if rb in cards
)
if not has_active_parent:
roots.append(stem)
return sorted(roots)
def walk_chain(
cards: dict[str, dict],
stem: str,
console: Console,
show_all: bool,
visited: set[str] | None = None,
depth: int = 0,
) -> None:
"""Walk the dependency tree depth-first from a card."""
if visited is None:
visited = set()
if stem in visited:
console.print(f"{' ' * depth}[dim](circular: {stem})[/dim]")
return
visited.add(stem)
card = cards.get(stem)
if card is None:
console.print(f"{' ' * depth}[red](missing: {stem})[/red]")
return
active = is_active(card)
if active or show_all:
# Print full card content
console.print()
separator = "=" * 72
console.print(f"[bold cyan]{separator}[/bold cyan]")
status_tag = "[active]" if active else "[complete]"
console.print(
f"[bold]{status_tag} {stem}[/bold] — {card['title']}"
)
console.print(f"[dim]{card['path'].relative_to(DOCS_DIR)}[/dim]")
if card["requires"]:
console.print(f"[dim]requires: {', '.join(card['requires'])}[/dim]")
if card["required_by"]:
console.print(
f"[dim]required-by: {', '.join(card['required_by'])}[/dim]"
)
console.print(f"[bold cyan]{separator}[/bold cyan]")
console.print()
content = card["path"].read_text()
console.print(content)
else:
# One-line summary for complete cards
console.print(
f"{' ' * depth}[green][complete][/green] {stem} — {card['title']}"
)
# Recurse into dependencies
for req in card["requires"]:
walk_chain(cards, req, console, show_all, visited, depth + 1)
def main(
card: Annotated[
str | None, typer.Argument(help="Card stem to show chain for")
] = None,
all: Annotated[
bool,
typer.Option("--all", help="Show all cards in full, including complete ones"),
] = False,
) -> None:
console = Console()
cards = build_graph()
# Check for circular dependencies
cycles = detect_cycles(cards)
if cycles:
console.print("[bold red]Circular dependencies detected![/bold red]")
for cycle in cycles:
console.print(f" [red]{' → '.join(cycle)}[/red]")
console.print()
if card is None:
# List all active Mikado chains
roots = find_root_goals(cards)
if not roots:
console.print("[dim]No active Mikado chains found.[/dim]")
console.print(
"[dim]Cards need status: active in frontmatter to appear here.[/dim]"
)
raise typer.Exit()
table = Table(
title="Active Mikado Chains", show_header=True, header_style="bold"
)
table.add_column("Goal Card")
table.add_column("Title")
table.add_column("Active Deps", justify="right")
table.add_column("Total Deps", justify="right")
for stem in roots:
card_data = cards[stem]
# Count dependencies
def count_deps(s: str, seen: set[str] | None = None) -> tuple[int, int]:
if seen is None:
seen = set()
if s in seen:
return 0, 0
seen.add(s)
active_count = 0
total_count = 0
c = cards.get(s)
if c is None:
return 0, 0
for req in c["requires"]:
total_count += 1
req_card = cards.get(req)
if req_card and is_active(req_card):
active_count += 1
sub_active, sub_total = count_deps(req, seen)
active_count += sub_active
total_count += sub_total
return active_count, total_count
active_deps, total_deps = count_deps(stem)
table.add_row(
f"[bold]{stem}[/bold]",
card_data["title"],
str(active_deps),
str(total_deps),
)
console.print()
console.print(table)
console.print()
console.print(
"[dim]Run: mise run docs-mikado <card> to see full chain[/dim]"
)
else:
if card not in cards:
console.print(f"[red]Card not found: {card}[/red]")
console.print("[dim]Available cards with status: active:[/dim]")
for stem, data in sorted(cards.items()):
if is_active(data):
console.print(f" {stem} — {data['title']}")
raise typer.Exit(code=1)
walk_chain(cards, card, console, show_all=all)
if __name__ == "__main__":
typer.run(main)