304 lines
9.6 KiB
Plaintext
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 "./onref")
|
||
|
|
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 }
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|