#!/usr/bin/env nu # reflection/modules/generator.nu — project output generators. # Composes full documentation from ontology, ADRs, modes, crates, and scenarios. # Output formats: md (human), json (agent/MCP), mdbook (publishable HTML). # # This is the implementation behind the generate-docs and generate-mdbook modes. # Modes declare steps; this module does the actual work. def project-root []: nothing -> string { let pr = ($env.ONTOREF_PROJECT_ROOT? | default "") if ($pr | is-not-empty) and ($pr != $env.ONTOREF_ROOT) { $pr } else { $env.ONTOREF_ROOT } } use ../modules/store.nu [daemon-export-safe] def nickel-import-path [root: string]: nothing -> string { let entries = [ $"($root)/.ontology" $"($root)/adrs" $"($root)/.ontoref/ontology/schemas" $"($root)/.ontoref/adrs" $"($root)/.onref" $root $"($env.ONTOREF_ROOT)/ontology" $"($env.ONTOREF_ROOT)/ontology/schemas" $"($env.ONTOREF_ROOT)/adrs" $env.ONTOREF_ROOT ] let valid = ($entries | where { |p| $p | path exists } | uniq) let existing = ($env.NICKEL_IMPORT_PATH? | default "") if ($existing | is-not-empty) { ($valid | append $existing) | str join ":" } else { $valid | str join ":" } } def ncl-export-safe [root: string, file: string]: nothing -> record { let ip = (nickel-import-path $root) daemon-export-safe $file --import-path $ip | default {} } # ── Data extraction ────────────────────────────────────────────────────────── def extract-identity [root: string]: nothing -> record { let cargo = $"($root)/Cargo.toml" mut name = ($root | path basename) mut version = "" mut description = "" mut crates = [] if ($cargo | path exists) { let cargo_data = (open $cargo) if ($cargo_data | get -o package.name | is-not-empty) { $name = ($cargo_data | get package.name) $version = ($cargo_data | get -o package.version | default "") $description = ($cargo_data | get -o package.description | default "") } # Workspace members let members = ($cargo_data | get -o workspace.members | default []) if ($members | is-not-empty) { $crates = ($members | each { |m| let member_cargo = $"($root)/($m)/Cargo.toml" # Expand globs: crates/* → actual directories if ($m | str contains "*") { glob $"($root)/($m)/Cargo.toml" | each { |f| let crate_dir = ($f | path dirname) let crate_data = (open $f) { name: ($crate_data | get -o package.name | default ($crate_dir | path basename)), path: ($crate_dir | str replace $"($root)/" ""), description: ($crate_data | get -o package.description | default ""), version: ($crate_data | get -o package.version | default ""), } } } else if ($member_cargo | path exists) { let crate_data = (open $member_cargo) [{ name: ($crate_data | get -o package.name | default ($m | path basename)), path: $m, description: ($crate_data | get -o package.description | default ""), version: ($crate_data | get -o package.version | default ""), }] } else { [] } } | flatten) } } { name: $name, version: $version, description: $description, crates: $crates, root: $root } } def extract-ontology [root: string]: nothing -> record { let core_file = $"($root)/.ontology/core.ncl" if not ($core_file | path exists) { return { nodes: [], edges: [] } } let core = (ncl-export-safe $root $core_file) if ($core | is-empty) { return { nodes: [], edges: [] } } { nodes: ($core.nodes? | default []), edges: ($core.edges? | default []), } } def extract-state [root: string]: nothing -> list { let state_file = $"($root)/.ontology/state.ncl" if not ($state_file | path exists) { return [] } let state = (ncl-export-safe $root $state_file) if ($state | is-empty) { return [] } $state.dimensions? | default [] } def extract-gates [root: string]: nothing -> list { let gate_file = $"($root)/.ontology/gate.ncl" if not ($gate_file | path exists) { return [] } let gate = (ncl-export-safe $root $gate_file) if ($gate | is-empty) { return [] } $gate.gates? | default [] | where { |g| ($g.active? | default false) == true } } def extract-adrs [root: string]: nothing -> list { let files = (glob $"($root)/adrs/adr-*.ncl") $files | each { |f| let data = (ncl-export-safe $root $f) if ($data | is-not-empty) { { id: ($data.id? | default ""), title: ($data.title? | default ""), status: ($data.status? | default ""), date: ($data.date? | default ""), decision: ($data.decision? | default ""), constraints: ($data.constraints? | default []), } } else { null } } | compact } def extract-modes [root: string]: nothing -> list { let ontoref_modes = (glob $"($env.ONTOREF_ROOT)/reflection/modes/*.ncl") let project_modes = if $root != $env.ONTOREF_ROOT { glob $"($root)/reflection/modes/*.ncl" } else { [] } let all_files = ($ontoref_modes | append $project_modes | uniq) $all_files | each { |f| let data = (ncl-export-safe $root $f) if ($data | is-not-empty) and ($data.id? | is-not-empty) { { id: ($data.id? | default ""), trigger: ($data.trigger? | default ""), steps: ($data.steps? | default [] | length), source: (if ($f | str starts-with $root) { $f | str replace $"($root)/" "" } else { $f | str replace $"($env.ONTOREF_ROOT)/" "onref/" }), } } else { null } } | compact } def extract-scenarios [root: string]: nothing -> list { let scenarios_dir = $"($root)/reflection/scenarios" if not ($scenarios_dir | path exists) { return [] } let dirs = (ls $scenarios_dir | where type == dir | get name) $dirs | each { |d| let scenario_ncl = $"($d)/scenario.ncl" let meta = if ($scenario_ncl | path exists) { ncl-export-safe $root $scenario_ncl } else { {} } let files = (ls $d | where type == file | get name | each { |f| $f | path basename }) { category: ($d | path basename), path: ($d | str replace $"($root)/" ""), files: $files, actor: ($meta.actor? | default ""), purpose: ($meta.purpose? | default ""), } } } # ── Compose full document ──────────────────────────────────────────────────── def compose-doc-data [root: string]: nothing -> record { let identity = (extract-identity $root) let ontology = (extract-ontology $root) let state = (extract-state $root) let gates = (extract-gates $root) let adrs = (extract-adrs $root) let modes = (extract-modes $root) let scenarios = (extract-scenarios $root) # Classify ontology nodes by level. let nodes = ($ontology.nodes? | default []) let axioms = ($nodes | where { |n| ($n.invariant? | default false) == true }) let tensions = ($nodes | where { |n| ($n.level? | default "") == "Tension" }) let practices = ($nodes | where { |n| let l = ($n.level? | default ""); $l == "Practice" or $l == "Spiral" }) { identity: $identity, architecture: { axioms: ($axioms | each { |n| { id: $n.id, name: ($n.name? | default ""), description: ($n.description? | default ""), artifact_paths: ($n.artifact_paths? | default []) } }), tensions: ($tensions | each { |n| { id: $n.id, name: ($n.name? | default ""), description: ($n.description? | default "") } }), practices: ($practices | each { |n| { id: $n.id, name: ($n.name? | default ""), description: ($n.description? | default ""), artifact_paths: ($n.artifact_paths? | default []) } }), edges: ($ontology.edges? | default []), }, state: ($state | each { |d| { id: ($d.id? | default ""), name: ($d.name? | default ""), current: ($d.current_state? | default ""), desired: ($d.desired_state? | default "") } }), gates: ($gates | each { |g| { id: ($g.id? | default ""), protects: ($g.protects? | default []), condition: ($g.opening_condition? | default "") } }), decisions: ($adrs | where { |a| $a.status == "Accepted" }), decisions_all: $adrs, modes: $modes, scenarios: $scenarios, } } # ── Format: Markdown ───────────────────────────────────────────────────────── def render-md [data: record]: nothing -> string { let id = $data.identity mut lines = [$"# ($id.name)"] if ($id.version | is-not-empty) { $lines = ($lines | append $"**Version**: ($id.version)") } if ($id.description | is-not-empty) { $lines = ($lines | append "") $lines = ($lines | append $id.description) } $lines = ($lines | append "") # Crates if ($id.crates | is-not-empty) { $lines = ($lines | append "## Crates") $lines = ($lines | append "") $lines = ($lines | append "| Crate | Description |") $lines = ($lines | append "|-------|-------------|") for c in $id.crates { $lines = ($lines | append $"| `($c.name)` | ($c.description) |") } $lines = ($lines | append "") } # Architecture — Axioms if ($data.architecture.axioms | is-not-empty) { $lines = ($lines | append "## Invariants") $lines = ($lines | append "") for a in $data.architecture.axioms { $lines = ($lines | append $"### ($a.name)") $lines = ($lines | append "") $lines = ($lines | append $a.description) if ($a.artifact_paths | is-not-empty) { let paths = ($a.artifact_paths | each { |p| $"`($p)`" } | str join ", ") $lines = ($lines | append $"Artifacts: ($paths)") } $lines = ($lines | append "") } } # Architecture — Tensions if ($data.architecture.tensions | is-not-empty) { $lines = ($lines | append "## Tensions") $lines = ($lines | append "") for t in $data.architecture.tensions { let poles = if ($t.poles | is-not-empty) { let pole_strs = ($t.poles | each { |p| $"($p.pole? | default '')" }) let joined = ($pole_strs | str join " ↔ ") $" (char lparen)($joined)(char rparen)" } else { "" } $lines = ($lines | append $"- **($t.name)**($poles): ($t.description)") } $lines = ($lines | append "") } # Architecture — Systems/Practices if ($data.architecture.practices | is-not-empty) { $lines = ($lines | append "## Systems") $lines = ($lines | append "") for p in $data.architecture.practices { $lines = ($lines | append $"### ($p.name)") $lines = ($lines | append "") $lines = ($lines | append $p.description) if ($p.artifact_paths | is-not-empty) { $lines = ($lines | append "") for ap in $p.artifact_paths { $lines = ($lines | append $"- `($ap)`") } } $lines = ($lines | append "") } } # State dimensions if ($data.state | is-not-empty) { $lines = ($lines | append "## State") $lines = ($lines | append "") $lines = ($lines | append "| Dimension | Current | Desired |") $lines = ($lines | append "|-----------|---------|---------|") for d in $data.state { let marker = if $d.current == $d.desired { " ✓" } else { "" } $lines = ($lines | append $"| ($d.name) | `($d.current)` | `($d.desired)`($marker) |") } $lines = ($lines | append "") } # Gates if ($data.gates | is-not-empty) { $lines = ($lines | append "## Active Gates") $lines = ($lines | append "") for g in $data.gates { $lines = ($lines | append $"- **($g.id)**: protects `($g.protects)` — ($g.condition)") } $lines = ($lines | append "") } # Decisions if ($data.decisions | is-not-empty) { $lines = ($lines | append "## Decisions (ADRs)") $lines = ($lines | append "") for a in $data.decisions { $lines = ($lines | append $"### ($a.id): ($a.title)") $lines = ($lines | append "") $lines = ($lines | append $a.decision) if ($a.constraints | is-not-empty) { let hard = ($a.constraints | where { |c| ($c.severity? | default "") == "Hard" }) if ($hard | is-not-empty) { $lines = ($lines | append "") $lines = ($lines | append "**Hard constraints:**") $lines = ($lines | append "") for c in $hard { $lines = ($lines | append $"- ($c.description? | default $c.scope)") } } } $lines = ($lines | append "") } } # Modes if ($data.modes | is-not-empty) { $lines = ($lines | append "## Operational Modes") $lines = ($lines | append "") for m in $data.modes { let step_count = $"(char lparen)($m.steps) steps(char rparen)" $lines = ($lines | append $"- **($m.id)** ($step_count): ($m.trigger)") } $lines = ($lines | append "") } # Scenarios if ($data.scenarios | is-not-empty) { $lines = ($lines | append "## Scenarios") $lines = ($lines | append "") for s in $data.scenarios { let purpose_str = if ($s.purpose | is-not-empty) { $" [($s.purpose)]" } else { "" } let file_count = $"(char lparen)($s.files | length) files(char rparen)" $lines = ($lines | append $"- **($s.category)**($purpose_str): `($s.path)/` ($file_count)") } $lines = ($lines | append "") } $lines | str join "\n" } # ── Format: mdBook ─────────────────────────────────────────────────────────── def render-mdbook [data: record, root: string] { let docs_src = $"($root)/docs/src" mkdir $docs_src mkdir $"($docs_src)/architecture" mkdir $"($docs_src)/decisions" mkdir $"($docs_src)/modes" # README / intro let intro = ([ $"# ($data.identity.name)" "" ($data.identity.description) "" $"Generated from project ontology and reflection data." ] | str join "\n") $intro | save -f $"($docs_src)/README.md" # Architecture page let arch_data = { identity: $data.identity, architecture: $data.architecture, state: $data.state, gates: $data.gates, decisions: [], decisions_all: [], modes: [], scenarios: [], } let arch_md = (render-md $arch_data) $arch_md | save -f $"($docs_src)/architecture/overview.md" # Individual ADR pages for adr in $data.decisions_all { let adr_data = { identity: { name: "", version: "", description: "", crates: [], root: "" }, architecture: { axioms: [], tensions: [], practices: [], edges: [] }, state: [], gates: [], decisions: (if $adr.status == "Accepted" { [$adr] } else { [] }), decisions_all: [$adr], modes: [], scenarios: [], } let status_badge = match $adr.status { "Accepted" => "✅ Accepted", "Proposed" => "📝 Proposed", "Superseded" => "🔄 Superseded", _ => $adr.status, } let content = ([ $"# ($adr.id): ($adr.title)" "" $"**Status**: ($status_badge) **Date**: ($adr.date)" "" $adr.decision ] | str join "\n") $content | save -f $"($docs_src)/decisions/($adr.id).md" } # ADR index let adr_index_lines = (["# Decisions (ADRs)" ""] | append ( $data.decisions_all | each { |a| let link = $"(char lparen)./($a.id).md(char rparen)" $"- [($a.id): ($a.title)]($link) — ($a.status)" } )) ($adr_index_lines | str join "\n") | save -f $"($docs_src)/decisions/README.md" # Modes page let modes_lines = (["# Operational Modes" ""] | append ( $data.modes | each { |m| let step_count = $"(char lparen)($m.steps) steps(char rparen)" $"- **($m.id)** ($step_count): ($m.trigger)" } )) ($modes_lines | str join "\n") | save -f $"($docs_src)/modes/README.md" # SUMMARY.md mut summary = [ "# Summary" "" "[Introduction](README.md)" "" "# Architecture" "" "- [Overview](architecture/overview.md)" "" "# Decisions" "" ] for adr in $data.decisions_all { $summary = ($summary | append $"- [($adr.id)](decisions/($adr.id).md)") } $summary = ($summary | append ["" "# Operations" "" "- [Modes](modes/README.md)"]) ($summary | str join "\n") | save -f $"($docs_src)/SUMMARY.md" # book.toml let book_toml = $"[book] authors = [\"Generated by ontoref\"] language = \"en\" multilingual = false src = \"src\" title = \"($data.identity.name) Documentation\" [output.html] default-theme = \"navy\" preferred-dark-theme = \"navy\" " let book_file = $"($root)/docs/book.toml" if not ($book_file | path exists) { $book_toml | save -f $book_file } print $" (ansi green)Generated:(ansi reset) ($docs_src)/SUMMARY.md" print $" (ansi green)Generated:(ansi reset) ($docs_src)/README.md" print $" (ansi green)Generated:(ansi reset) ($docs_src)/architecture/overview.md" let adr_count = ($data.decisions_all | length) print $" (ansi green)Generated:(ansi reset) ($adr_count) ADR pages in ($docs_src)/decisions/" print $" (ansi green)Generated:(ansi reset) ($docs_src)/modes/README.md" # Build if mdbook is available let has_mdbook = (do { ^which mdbook } | complete | get exit_code) == 0 if $has_mdbook { print "" print $" (ansi cyan)Building mdBook...(ansi reset)" let result = (do { ^mdbook build $"($root)/docs/" } | complete) if $result.exit_code == 0 { print $" (ansi green)Book built:(ansi reset) ($root)/docs/book/" } else { print $" (ansi yellow)Build failed:(ansi reset) ($result.stderr | str trim)" print $" (ansi dark_gray)Run manually: mdbook build docs/(ansi reset)" } } else { print "" print $" (ansi dark_gray)mdbook not found. Install: cargo install mdbook(ansi reset)" print $" (ansi dark_gray)Then: mdbook build docs/(ansi reset)" } } # ── Public API ─────────────────────────────────────────────────────────────── export def "docs generate" [ --fmt (-f): string = "", # Output format: md | json | yaml | mdbook (short: m j y) ]: nothing -> nothing { let root = (project-root) let actor = ($env.ONTOREF_ACTOR? | default "developer") let raw_fmt = if ($fmt | is-not-empty) { $fmt } else if $actor == "agent" { "json" } else { "md" } let f = match $raw_fmt { "j" => "json", "y" => "yaml", "m" => "md", _ => $raw_fmt, } let data = (compose-doc-data $root) match $f { "json" => { print ($data | to json) }, "yaml" => { print ($data | to yaml) }, "md" => { print (render-md $data) }, "mdbook" => { render-mdbook $data $root }, _ => { print $" Unknown format: ($f). Available: md | json | yaml | mdbook" }, } } export def "docs formats" []: nothing -> nothing { print "" print " Available documentation formats:" print "" print $" (ansi cyan)md(ansi reset) Markdown document to stdout (default for humans)" print $" (ansi cyan)json(ansi reset) Structured JSON to stdout (default for agents)" print $" (ansi cyan)yaml(ansi reset) YAML to stdout" print $" (ansi cyan)mdbook(ansi reset) Generates docs/src/ + SUMMARY.md, builds if mdbook installed" print "" print $" (ansi dark_gray)Usage: strat docs generate --fmt (ansi reset)" print $" (ansi dark_gray)Short: strat docs generate -f j(ansi reset)" print "" }