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
498 lines
17 KiB
Text
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 { [] })
|
|
}
|