ontoref/reflection/modules/workflow.nu
Jesús Pérez 6721daf440
Some checks failed
Nickel Type Check / Nickel Type Checking (push) Has been cancelled
Rust CI / Security Audit (push) Has been cancelled
Rust CI / Check + Test + Lint (nightly) (push) Has been cancelled
Rust CI / Check + Test + Lint (stable) (push) Has been cancelled
feat: workflow layer model — NCL-first CI/build/distribution generator
Adds a declarative workflow system where .ontology/workflow.ncl declares
  independent layers (commit-fast, ci-standard, ci-exhaustive), each with a
  trigger and a set of providers. The generator materialises Woodpecker YAML,
  justfile recipes, and pre-commit hook stubs from that single declaration.

  Layers form a set, not a chain — no layer depends on another at the
  declaration level. Woodpecker steps express fine-grained parallelism
  internally (lint → test/docs/build).

  Schema and catalog:
  - reflection/schemas/workflow.ncl — types, contracts, provider constructors
  - reflection/defaults/workflow.ncl — standard Rust/Nushell/Nickel catalog
    (14 validations, 4 builds)

  Generator (ore workflow generate):
  - PreCommit: prints hook fragments for manual merge into .pre-commit-config.yaml
  - Woodpecker: writes per-layer YAML with image selection, tool installs,
    depends_on chains, and CI-aware command translation
    (nu --ide-check → find+xargs; markdownlint-cli2 → node:lts image)
  - Justfile: single justfiles/workflow.just aggregated from all Justfile layers
    (hoisted out of per-provider loop — was writing N times, now once)

  Dispatcher: ore workflow validate|list|generate|diff + ore wf v|l|gen|diff

  Cargo/nextest profiles:
  - .cargo/config.toml: [profile.ci-test] (debug=0, no-incremental) and
    [profile.ci] (line-tables-only) — shared by test + docs hooks to reuse
    .rlib artifacts; CI retains actionable backtraces
  - .config/nextest.toml: ci-test (fail-fast, no retries) and ci (all failures,
    one retry)

  Pre-commit: nextest run with --profile ci-test --cargo-profile ci-test;
  docs-links hook shares same profile to reuse artifacts

  Self-application: ontoref declares its own workflow in .ontology/workflow.ncl
  and the generated .woodpecker/ files are the authoritative CI definition

  Migration 0014: checks workflow.ncl + both cargo and nextest profiles present

  Adoption: reflection/templates/ontology/workflow.ncl stub + install_workflow
  confirm in adopt_ontoref form + step 5 in adoption script template
2026-04-08 14:20:38 +01:00

498 lines
17 KiB
Text

#!/usr/bin/env nu
# reflection/modules/workflow.nu — workflow layer model generator.
#
# Reads .ontology/workflow.ncl (WorkflowDeclaration) and generates implementation
# artifacts for each declared provider. Layers are independent — generating one
# layer never affects another.
#
# Commands:
# workflow validate — typecheck workflow.ncl against schema
# workflow generate — generate all providers for all layers
# workflow generate --layer <id> — generate one layer
# workflow generate --provider <tag> — generate one provider type across all layers
# workflow diff — show what would change without writing
# workflow list — list declared layers + providers
use env.nu *
use store.nu [daemon-export-safe]
# ── Helpers ──────────────────────────────────────────────────────────────────
# Join args with space, trimming trailing whitespace.
# Prevents trailing-space commands like `markdownlint-cli2 `.
def args-str [args: list<string>]: nothing -> string {
$args | str join ' '
}
# Build a tool invocation string from tool name + args, trimmed.
def tool-cmd [tool: string, args: list<string>]: nothing -> string {
let base = if ($tool == "cargo-nextest") {
$"cargo nextest (args-str $args)"
} else if ($tool == "cargo-deny") {
$"cargo deny (args-str $args)"
} else if ($tool == "cargo-geiger") {
$"cargo geiger (args-str $args)"
} else if ($tool == "cargo-sbom") {
$"cargo sbom (args-str $args)"
} else if ($tool =~ "^cargo-") {
let subcmd = ($tool | str replace "cargo-" "")
$"cargo ($subcmd) (args-str $args)"
} else if ($tool == "cargo") {
$"cargo (args-str $args)"
} else {
$"($tool) (args-str $args)"
}
$base | str trim --right
}
def project-root []: nothing -> string {
$env.ONTOREF_PROJECT_ROOT? | default $env.ONTOREF_ROOT
}
def workflow-ncl-path [root: string]: nothing -> string {
[$root, ".ontology", "workflow.ncl"] | path join
}
def nickel-import-path [root: string]: nothing -> string {
let entries = [
$"($root)/.ontology"
$"($root)/reflection/schemas"
$"($root)/reflection/defaults"
$"($root)/adrs"
$root
$"($env.ONTOREF_ROOT)/reflection/schemas"
$"($env.ONTOREF_ROOT)/reflection/defaults"
$env.ONTOREF_ROOT
]
($entries | where { |p| $p | path exists } | uniq | str join ":")
}
def load-workflow [root: string]: nothing -> record {
let ncl = (workflow-ncl-path $root)
if not ($ncl | path exists) {
error make { msg: $"workflow.ncl not found at ($ncl) — run: ore workflow generate --help" }
}
let ip = (nickel-import-path $root)
let result = do { ^nickel export --import-path $ip $ncl } | complete
if $result.exit_code != 0 {
error make { msg: $"workflow.ncl export failed:\n($result.stderr)" }
}
$result.stdout | from json
}
# Resolve an ID to its spec from a catalog record; error with catalog-name context.
def resolve-spec [id: string, catalog: record, kind: string]: nothing -> record {
if ($catalog | get -o $id | is-not-empty) {
$catalog | get $id
} else {
error make { msg: $"($kind) '($id)' not found in catalog" }
}
}
def resolve-validation [id: string, catalog: record]: nothing -> record {
resolve-spec $id $catalog "validation"
}
def resolve-build [id: string, catalog: record]: nothing -> record {
resolve-spec $id $catalog "build"
}
# ── Pre-commit generator ─────────────────────────────────────────────────────
# Translate a ValidationSpec into a pre-commit hook entry (YAML fragment).
def validation-to-precommit [v: record]: nothing -> string {
let entry_cmd = (tool-cmd $v.tool $v.args)
let env_prefix = if ($v.tool == "cargo" and ($v.args | any { |a| $a == "doc" })) {
'RUSTDOCFLAGS="-D rustdoc::broken-intra-doc-links -D rustdoc::private-intra-doc-links" '
} else {
""
}
let types_line = if ($v.when | is-empty) {
""
} else {
# Map glob patterns to pre-commit type hints or regex `files:`.
# Dots are escaped so `Cargo.toml` does not match `CargoXtoml`.
let has_rust = ($v.when | any { |p| $p =~ '\.rs' })
let has_md = ($v.when | any { |p| $p =~ '\.md' })
if $has_rust {
"\n types: [rust]"
} else if $has_md {
"\n types: [markdown]"
} else {
# Convert glob → Python re.search() pattern.
# Order matters: escape literal dots first, then convert glob * to regex .*
let files_pat = ($v.when | each { |p|
$p | str replace --all "." "\\." | str replace --all "*" ".*"
} | str join "|")
$"\n files: '($files_pat)'"
}
}
$" - id: ($v.id)
name: ($v.id)
entry: bash -c '($env_prefix)($entry_cmd)'
language: system
pass_filenames: false($types_line)
stages: [pre-commit]"
}
def generate-precommit [wf: record, stage: string, dry_run: bool, root: string]: nothing -> nothing {
let layers = ($wf.layers | where { |l|
($l.providers | any { |p| $p.tag == "PreCommit" and ($p.stage? | default "pre-commit") == $stage })
})
if ($layers | is-empty) {
print $" no layers with PreCommit/($stage) provider — skipping"
return
}
# Collect all validation IDs across matching layers (deduplicated, order preserved)
mut seen = []
mut specs = []
for layer in $layers {
for vid in $layer.validations {
if not ($seen | any { |s| $s == $vid }) {
$seen = ($seen | append $vid)
$specs = ($specs | append (resolve-validation $vid $wf.validations))
}
}
}
let hook_entries = ($specs | each { |v| validation-to-precommit $v } | str join "\n")
let section = $" - repo: local
hooks:
($hook_entries)"
let out_path = ([$root, ".pre-commit-config.yaml"] | path join)
if $dry_run {
print $"[dry-run] would update pre-commit hooks in ($out_path):"
print $section
return
}
# If file exists, replace the '- repo: local' block managed by workflow.
# If not, write a minimal pre-commit config.
if ($out_path | path exists) {
print $" pre-commit config exists — merge is not automatic"
print $" copy the following into the 'repo: local' section of ($out_path):"
print ""
print $section
} else {
let content = $"repos:
($section)
"
$content | save $out_path
print $" wrote ($out_path)"
}
}
# ── Woodpecker generator ─────────────────────────────────────────────────────
# Map a tool name to the shell command that installs it in a CI container.
def tool-install-cmd [tool: string]: nothing -> string {
match $tool {
"cargo" => "",
"cargo-nextest" => "cargo install cargo-nextest --locked",
"cargo-deny" => "cargo install cargo-deny --locked",
"cargo-geiger" => "cargo install cargo-geiger --locked",
"cargo-sbom" => "cargo install cargo-sbom --locked",
"cargo-llvm-cov" => "cargo install cargo-llvm-cov --locked",
"cross" => "cargo install cross --locked",
"nickel" => "cargo install nickel-lang-cli --locked",
"nu" => "cargo install nu --locked",
"markdownlint-cli2" => "npm install -g markdownlint-cli2",
_ => $"# install ($tool)",
}
}
# Map a tool to its required container image for CI.
# Most tools run from rust:latest; node-based tools need a node image.
def tool-image [tool: string]: nothing -> string {
match $tool {
"markdownlint-cli2" => "node:lts",
_ => "rust:latest",
}
}
# Build the CI command for a ValidationSpec.
# `nu --ide-check` requires a filename — the CI version wraps it in a find loop.
def validation-ci-cmd [v: record]: nothing -> string {
if $v.tool == "nu" and ($v.args | any { |a| $a == "--ide-check" }) {
let depth = ($v.args | window 2 | where { |w| $w.0 == "--ide-check" } | first | default ["--ide-check","100"] | last | default "100")
$"find . -name '*.nu' ! -path '*/target/*' -print0 | xargs -0 -I\\{\\} nu --ide-check ($depth) \\{\\}"
} else if ($v.tool == "cargo" and ($v.args | any { |a| $a == "doc" })) {
let cmd = (tool-cmd $v.tool $v.args)
$'RUSTDOCFLAGS="-D rustdoc::broken-intra-doc-links -D rustdoc::private-intra-doc-links" ($cmd)'
} else {
tool-cmd $v.tool $v.args
}
}
def validation-to-woodpecker-step [v: record]: nothing -> string {
let install = (tool-install-cmd $v.tool)
let install_lines = if ($install | is-not-empty) { $" - ($install)\n" } else { "" }
let image = (tool-image $v.tool)
let cmd = (validation-ci-cmd $v)
$" ($v.id):
image: ($image)
commands:
($install_lines) - ($cmd)"
}
def build-to-woodpecker-step [b: record]: nothing -> string {
let install = (tool-install-cmd $b.tool)
let install_lines = if ($install | is-not-empty) { $" - ($install)\n" } else { "" }
let image = (tool-image $b.tool)
let cmd = (tool-cmd $b.tool $b.args)
$" build-($b.id):
image: ($image)
commands:
($install_lines) - ($cmd)"
}
def trigger-to-woodpecker-when [trigger: string]: nothing -> string {
match $trigger {
"OnCommit" => " event: [push, manual]",
"OnPush" => " event: [push, manual]",
"OnPR" => " event: [push, pull_request, manual]",
"OnMainMerge" => " event: [push, tag, manual]\n branch: main",
"OnTag" => " event: [tag, manual]",
"OnManual" => " event: [manual]",
_ => " event: [push, pull_request, manual]",
}
}
def generate-woodpecker [layer: record, wf: record, file: string, dry_run: bool, root: string]: nothing -> nothing {
let when_block = (trigger-to-woodpecker-when $layer.trigger)
# Collect unique tools needed → depends_on for test after lint
let val_specs = ($layer.validations | each { |vid| resolve-validation $vid $wf.validations })
let build_specs = ($layer.builds | each { |bid| resolve-build $bid $wf.builds })
let lint_steps = ($val_specs | where { |v| $v.kind == "Lint" or $v.kind == "Compliance" })
let test_steps = ($val_specs | where { |v| $v.kind == "Test" })
let sec_steps = ($val_specs | where { |v| $v.kind == "Security" })
let doc_steps = ($val_specs | where { |v| $v.kind == "Docs" })
let lint_ids = ($lint_steps | get id)
let depends = if ($lint_ids | is-empty) { "" } else {
let ids_str = ($lint_ids | each { |id| $"\"($id)\"" } | str join ", ")
$"\n depends_on: [($ids_str)]"
}
mut steps_yaml = ""
# Lint steps (parallel)
for v in $lint_steps {
$steps_yaml = $steps_yaml + (validation-to-woodpecker-step $v) + "\n\n"
}
# Test steps (depends on lint)
for v in $test_steps {
let step = (validation-to-woodpecker-step $v)
$steps_yaml = $steps_yaml + $step + $depends + "\n environment:\n RUST_BACKTRACE: 1\n\n"
}
# Security steps (parallel — cargo deny / geiger do not need prior compilation)
for v in $sec_steps {
$steps_yaml = $steps_yaml + (validation-to-woodpecker-step $v) + "\n\n"
}
# Docs steps (depends on lint — can run in parallel with tests)
for v in $doc_steps {
let step = (validation-to-woodpecker-step $v)
$steps_yaml = $steps_yaml + $step + $depends + "\n\n"
}
# Build steps (depends on test)
for b in $build_specs {
let step = (build-to-woodpecker-step $b)
$steps_yaml = $steps_yaml + $step + $depends + "\n\n"
}
let yaml = $"# Generated by ore workflow generate — layer: ($layer.id)
# Source: .ontology/workflow.ncl
# Do not edit manually — regenerate with: ore workflow generate --layer ($layer.id)
when:
($when_block)
steps:
($steps_yaml)"
let out_path = ([$root, $file] | path join)
let out_dir = ($out_path | path dirname)
if $dry_run {
print $"[dry-run] would write ($out_path):"
print $yaml
return
}
if not ($out_dir | path exists) { mkdir $out_dir }
($yaml | str trim --right) + "\n" | save --force $out_path
print $" wrote ($out_path)"
}
# ── Justfile generator ───────────────────────────────────────────────────────
def generate-justfile [layers: list, wf: record, dry_run: bool, root: string]: nothing -> nothing {
let jf_layers = ($layers | where { |l|
$l.providers | any { |p| $p.tag == "Justfile" }
})
if ($jf_layers | is-empty) {
print " no layers with Justfile provider — skipping"
return
}
mut recipes = $"# Generated by ore workflow generate
# Source: .ontology/workflow.ncl
"
for layer in $jf_layers {
let recipe_name = ($layer.providers
| where { |p| $p.tag == "Justfile" }
| first
| get recipe)
let val_specs = ($layer.validations | each { |vid| resolve-validation $vid $wf.validations })
let build_specs = ($layer.builds | each { |bid| resolve-build $bid $wf.builds })
mut body = ""
for v in $val_specs {
$body = $body + $" (tool-cmd $v.tool $v.args)\n"
}
for b in $build_specs {
$body = $body + $" (tool-cmd $b.tool $b.args)\n"
}
$recipes = $recipes + $"# layer: ($layer.id)\n($recipe_name):\n($body)\n"
}
let out_path = ([$root, "justfiles", "workflow.just"] | path join)
if $dry_run {
print $"[dry-run] would write ($out_path)"
print $recipes
return
}
if not ($out_path | path dirname | path exists) { mkdir ($out_path | path dirname) }
($recipes | str trim --right) + "\n" | save --force $out_path
print $" wrote ($out_path)"
}
# ── Public commands ──────────────────────────────────────────────────────────
# Validate .ontology/workflow.ncl against the schema (nickel typecheck).
export def "workflow validate" [
--root: string # project root (default: ONTOREF_PROJECT_ROOT)
]: nothing -> nothing {
let r = ($root | default (project-root))
let ncl = (workflow-ncl-path $r)
let ip = (nickel-import-path $r)
print $"Validating ($ncl)..."
let result = do { ^nickel typecheck --import-path $ip $ncl } | complete
if $result.exit_code == 0 {
print " workflow.ncl OK"
} else {
error make { msg: $"workflow.ncl typecheck failed:\n($result.stderr)" }
}
}
# List declared layers and their providers.
export def "workflow list" [
--root: string
]: nothing -> table {
let r = ($root | default (project-root))
let wf = (load-workflow $r)
$wf.layers | each { |l|
{
id: $l.id,
trigger: $l.trigger,
validations: ($l.validations | length),
builds: ($l.builds | length),
providers: ($l.providers | each { |p| $p.tag } | str join ", "),
}
}
}
# Generate implementation artifacts from .ontology/workflow.ncl.
export def "workflow generate" [
--root: string # project root (default: ONTOREF_PROJECT_ROOT)
--layer: string # generate only this layer ID
--provider: string # generate only this provider tag (PreCommit, Woodpecker, Justfile)
--dry-run # show what would be written without writing
]: nothing -> nothing {
let r = ($root | default (project-root))
let wf = (load-workflow $r)
let dry = $dry_run
let target_layers = if ($layer | is-not-empty) {
let found = ($wf.layers | where { |l| $l.id == $layer })
if ($found | is-empty) {
error make { msg: $"layer '($layer)' not found in workflow.ncl" }
}
$found
} else {
$wf.layers
}
print $"Generating workflow artifacts from ($r)/.ontology/workflow.ncl"
mut justfile_needed = false
for layer in $target_layers {
print $"\n layer: ($layer.id) [($layer.trigger)]"
for prov in $layer.providers {
let tag = $prov.tag
if ($provider | is-not-empty) and $tag != $provider { continue }
match $tag {
"PreCommit" => {
let stage = ($prov | get -o stage | default "pre-commit")
print $" provider: PreCommit/($stage)"
generate-precommit $wf $stage $dry $r
},
"Woodpecker" => {
let file = $prov.file
print $" provider: Woodpecker → ($file)"
generate-woodpecker $layer $wf $file $dry $r
},
"Justfile" => {
# Defer: justfiles/workflow.just aggregates ALL Justfile layers.
# Generate once after the loop to avoid clobbering on multi-layer runs.
$justfile_needed = true
print " provider: Justfile → justfiles/workflow.just [deferred to post-loop]"
},
_ => {
print $" provider: ($tag) — not yet implemented"
},
}
}
}
# Justfile generation is hoisted here so it runs once with the full layer set.
if $justfile_needed {
print "\n generating justfiles/workflow.just"
generate-justfile $target_layers $wf $dry $r
}
print "\nDone."
}
# Show what would change without writing any files.
export def "workflow diff" [
--root: string
--layer: string
--provider: string
]: nothing -> nothing {
workflow generate --root ($root | default (project-root)) --dry-run
...(if ($layer | is-not-empty) { [--layer $layer] } else { [] })
...(if ($provider | is-not-empty) { [--provider $provider] } else { [] })
}