304 lines
9.6 KiB
Plaintext

#!/usr/bin/env nu
# reflection/modules/adr.nu — ADR management commands.
#
# Error handling policy:
# adr list / constraints — graceful skip via do { ^nickel export } | complete
# (draft ADRs may be incomplete; list must always work)
# adr validate — ^nickel export | complete for fresh data (no plugin cache)
# adr show — fail loud (caller passes a known valid id)
use env.nu *
use store.nu [daemon-export, daemon-export-safe]
# Formats an ADR record as readable markdown text.
def adr-to-md []: record -> string {
let a = $in
mut lines = []
$lines = ($lines | append $"# ($a.id): ($a.title)")
$lines = ($lines | append $"")
$lines = ($lines | append $"**Status**: ($a.status) **Date**: ($a.date)")
$lines = ($lines | append $"")
$lines = ($lines | append "## Context")
$lines = ($lines | append $a.context)
$lines = ($lines | append $"")
$lines = ($lines | append "## Decision")
$lines = ($lines | append $a.decision)
$lines = ($lines | append $"")
$lines = ($lines | append "## Rationale")
for r in $a.rationale {
$lines = ($lines | append $"- **($r.claim)**")
$lines = ($lines | append $" ($r.detail)")
}
$lines = ($lines | append $"")
$lines = ($lines | append "## Consequences")
$lines = ($lines | append "**Positive**")
for p in $a.consequences.positive {
$lines = ($lines | append $"- ($p)")
}
$lines = ($lines | append "**Negative**")
for n in $a.consequences.negative {
$lines = ($lines | append $"- ($n)")
}
$lines = ($lines | append $"")
$lines = ($lines | append "## Alternatives Considered")
for alt in $a.alternatives_considered {
$lines = ($lines | append $"- **($alt.option)**: ($alt.why_rejected)")
}
$lines = ($lines | append $"")
$lines = ($lines | append "## Constraints")
for c in $a.constraints {
$lines = ($lines | append $"### ($c.id) [($c.severity)]")
$lines = ($lines | append $c.claim)
$lines = ($lines | append $"- Scope: ($c.scope)")
$lines = ($lines | append $"- Check: `($c.check_hint)`")
$lines = ($lines | append $"- Rationale: ($c.rationale)")
$lines = ($lines | append $"")
}
let oc = $a.ontology_check
$lines = ($lines | append "## Ontology Check")
$lines = ($lines | append $"- Verdict: **($oc.verdict)**")
if ($oc.invariants_at_risk | is-not-empty) {
$lines = ($lines | append $"- Invariants at risk: ($oc.invariants_at_risk | str join ', ')")
}
if ($a.related_adrs | is-not-empty) {
$lines = ($lines | append $"")
$lines = ($lines | append $"## Related ADRs")
$lines = ($lines | append ($a.related_adrs | str join ", "))
}
$lines | str join "\n"
}
# Resolve effective format: explicit flag wins; otherwise md for humans, json for agents.
def resolve-fmt [fmt: string, human_default: string]: nothing -> string {
if ($fmt | is-not-empty) { return $fmt }
let actor = ($env.ONTOREF_ACTOR? | default "developer")
if $actor == "agent" { "json" } else { $human_default }
}
# Serialize or render a value in the requested format.
# fmt: "md" (default readable markdown) | "table" (Nu expand) | "json" | "yaml" | "toml"
def fmt-output [fmt: string]: any -> any {
let data = $in
match $fmt {
"json" => { $data | to json },
"yaml" => { $data | to yaml },
"toml" => {
# to toml requires a record at the top level — wrap lists
if (($data | describe) =~ "^(list|table)") {
{ items: $data } | to toml
} else {
$data | to toml
}
},
"table" => { $data | table --expand },
_ => {
if (($data | describe) =~ "^record") {
$data | adr-to-md
} else {
# list/table — render as markdown table
$data | to md
}
},
}
}
# Print a value — needed inside while loops where return values are discarded.
def print-output [fmt: string]: any -> nothing {
let data = $in
match $fmt {
"json" | "yaml" | "toml" => { print $data },
"table" => { print ($data | table --expand) },
_ => {
if (($data | describe) =~ "^record") {
print ($data | adr-to-md)
} else {
print ($data | to md)
}
},
}
}
export def "adr list" [
--fmt: string = "", # Output format: table (human) | json (agent) | yaml | toml
] {
let f = (resolve-fmt $fmt "table")
adr-files | each { |ncl|
let data = (daemon-export-safe $ncl)
if $data != null { $data | select id title status date } else { null }
} | compact | sort-by date | fmt-output $f
}
export def "adr validate" [] {
let hard = (
adr-files | each { |ncl|
daemon-export-safe $ncl
}
| compact
| where status == "Accepted"
| get constraints
| flatten
| where severity == "Hard"
)
let count = ($hard | length)
print $"Running ($count) Hard constraints..."
print ""
let results = $hard | each { |c|
let result = do { nu -c $c.check_hint } | complete
{
id: $c.id,
claim: $c.claim,
passed: ($result.exit_code == 0 and ($result.stdout | str trim | is-empty)),
output: ($result.stdout | str trim),
}
}
for r in $results {
let icon = if $r.passed { "✓" } else { "✗" }
print $"($icon) ($r.id): ($r.claim)"
if not $r.passed and ($r.output | is-not-empty) {
print $" Violation: ($r.output)"
}
}
let failures = $results | where passed == false
if ($failures | is-not-empty) {
let n = ($failures | length)
error make { msg: $"($n) Hard constraints violated" }
}
}
export def "adr show" [
id?: string, # ADR id: "001", "adr-001", or "adr-001-slug"
--interactive (-i), # Select from list interactively via typedialog
--fmt: string = "", # Output format: md (human) | json (agent) | table | yaml | toml
] {
let f = (resolve-fmt $fmt "md")
if $interactive or ($id | is-empty) {
adr-show-interactive $f
} else {
adr-show-by-id $id $f
}
}
export def "constraints" [
--fmt: string = "", # Output format: table (human) | json (agent) | yaml | toml
] {
let f = (resolve-fmt $fmt "table")
adr-files | each { |ncl|
daemon-export-safe $ncl
}
| compact
| where status == "Accepted"
| each { |a| $a.constraints | each { |c| $c | merge { adr: $a.id } } }
| flatten
| where severity == "Hard"
| select adr id claim check_hint
| fmt-output $f
}
export def "adr help" [] {
let actor = ($env.ONTOREF_ACTOR? | default "developer")
let cmd = ($env.ONTOREF_CALLER? | default "ontoref")
print ""
print "ADR commands:"
print $" ($cmd) adr list list all ADRs with status"
print $" ($cmd) adr list --fmt json output as json/yaml/toml"
print $" ($cmd) adr validate run Hard constraint checks"
print $" ($cmd) adr show interactive ADR browser (no args)"
print $" ($cmd) adr show <id> show a specific ADR"
print $" ($cmd) constraints show active Hard constraints"
if $actor == "agent" {
print ""
print "Agent query pattern:"
print " nickel-export adrs/adr-NNN-name.ncl"
print " nickel-export adrs/adr-NNN-name.ncl | get constraints | where severity == 'Hard'"
}
print ""
}
# ── Internal ───────────────────────────────────────────────────────────────────
export def "adr accept" [
id: string, # adr-NNN or NNN
] {
let root = ($env.ONTOREF_PROJECT_ROOT? | default $env.ONTOREF_ROOT)
let canonical = if ($id | str starts-with "adr-") { $id } else { $"adr-($id)" }
let files = glob $"($root)/adrs/($canonical)-*.ncl"
if ($files | is-empty) {
error make { msg: $"ADR '($id)' not found in adrs/" }
}
let path = ($files | first)
let content = open $path
if not ($content | str contains "'Proposed") {
error make { msg: $"ADR '($id)' is not in Proposed status — current file does not contain \"'Proposed\"" }
}
$content | str replace --all "'Proposed" "'Accepted" | save --force $path
print $" accepted: ($path | path basename)"
do { ^nickel typecheck $path } | complete
| if $in.exit_code != 0 {
error make { msg: $"typecheck failed after accept:\n($in.stderr)" }
} else {
print " typecheck: ok"
}
}
def adr-files [] {
let root = ($env.ONTOREF_PROJECT_ROOT? | default $env.ONTOREF_ROOT)
glob $"($root)/adrs/adr-*.ncl"
}
def adr-show-by-id [id: string, fmt: string] {
let root = ($env.ONTOREF_PROJECT_ROOT? | default $env.ONTOREF_ROOT)
let canonical = if ($id | str starts-with "adr-") { $id } else { $"adr-($id)" }
let files = glob $"($root)/adrs/($canonical)-*.ncl"
if ($files | is-empty) {
error make { msg: $"ADR '($id)' not found in adrs/" }
}
let data = (daemon-export ($files | first))
$data | fmt-output $fmt
}
def adr-show-interactive [fmt: string] {
let entries = (
adr-files | each { |ncl|
let data = (daemon-export-safe $ncl)
if $data != null { $data | select id title status } else { null }
} | compact | sort-by id
)
if ($entries | is-empty) {
print "No ADRs found."
return
}
let options = ($entries | each { |a| $"($a.id) ($a.title) [($a.status)]" })
let menu = ($options | append "— Exit —")
mut browsing = true
while $browsing {
let choice = (typedialog select "Select ADR:" $menu)
if $choice == "— Exit —" {
$browsing = false
} else {
let idx = ($options | enumerate | where { |r| $r.item == $choice } | first).index
let selected = ($entries | get $idx)
print ""
adr-show-by-id $selected.id $fmt | print-output $fmt
print ""
let next = (typedialog select "Continue?" ["Browse" "Exit"])
if $next == "Exit" { $browsing = false }
}
}
}