536 lines
19 KiB
Plaintext
536 lines
19 KiB
Plaintext
#!/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<record> {
|
|
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<record> {
|
|
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<record> {
|
|
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<record> {
|
|
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<record> {
|
|
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 <format>(ansi reset)"
|
|
print $" (ansi dark_gray)Short: strat docs generate -f j(ansi reset)"
|
|
print ""
|
|
}
|