feat: workflow layer model — NCL-first CI/build/distribution generator
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

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
This commit is contained in:
Jesús Pérez 2026-04-08 14:20:38 +01:00
parent a6e0287c6f
commit 6721daf440
Signed by: jesus
GPG key ID: 9F243E355E0BC939
15 changed files with 1163 additions and 66 deletions

View file

@ -46,6 +46,21 @@ overflow-checks = true
lto = false lto = false
incremental = true incremental = true
[profile.ci-test]
# Pre-commit profile: no debug info, no incremental.
# Shared by rust-test and docs-links hooks so both reuse the same .rlib artifacts.
# debug = 0 removes DWARF/dSYM — halves binary size without affecting correctness.
inherits = "test"
debug = 0
incremental = false
[profile.ci]
# CI pipeline profile: line-tables-only debug for useful backtraces on remote failures.
# Distinct from ci-test (debug=0): CI failures must show file:line to be actionable.
inherits = "test"
debug = "line-tables-only"
incremental = false
[profile.bench] [profile.bench]
# Benchmark profile - same as release # Benchmark profile - same as release
opt-level = 3 opt-level = 3

9
.config/nextest.toml Normal file
View file

@ -0,0 +1,9 @@
[profile.ci-test]
# Pre-commit profile: fail-fast on first failure, no retries.
fail-fast = true
retries = 0
[profile.ci]
# CI pipeline profile: see all failures, one retry for transient flakiness.
fail-fast = false
retries = 1

61
.ontology/workflow.ncl Normal file
View file

@ -0,0 +1,61 @@
let W = import "../reflection/schemas/workflow.ncl" in
let D = import "../reflection/defaults/workflow.ncl" in
{
layers = [
# ── Commit-fast: local pre-commit, independent ────────────────────────────
{
id = "commit-fast",
trigger = 'OnCommit,
validations = [
"rust-fmt",
"rust-clippy",
"nextest-ci-test",
"deny-subset",
"docs-drift",
"manifest-coverage",
"markdownlint",
],
builds = [],
distributions = [],
providers = [W.pre_commit "pre-commit"],
},
# ── CI-standard: push/PR pipeline, independent ────────────────────────────
{
id = "ci-standard",
trigger = 'OnPR,
validations = [
"rust-clippy-all",
"nextest-ci",
"deny-subset",
"docs-check",
"nickel-typecheck",
"nushell-check",
],
builds = ["release-native"],
distributions = [],
providers = [
W.woodpecker ".woodpecker/ci.yml",
W.justfile "ci-standard",
],
},
# ── CI-exhaustive: main/tag pipeline, independent ─────────────────────────
{
id = "ci-exhaustive",
trigger = 'OnMainMerge,
validations = ["deny-all", "geiger"],
builds = ["release-musl-x86", "sbom"],
distributions = [],
providers = [
W.woodpecker ".woodpecker/ci-exhaustive.yml",
W.justfile "ci-exhaustive",
],
},
],
validations = D.validations,
builds = D.builds,
distributions = {},
}

View file

@ -25,8 +25,8 @@ repos:
stages: [pre-commit] stages: [pre-commit]
- id: rust-test - id: rust-test
name: Rust tests name: Rust tests (nextest)
entry: bash -c 'cargo test --all-features --workspace' entry: bash -c 'cargo nextest run --all-features --workspace --profile ci-test --cargo-profile ci-test'
language: system language: system
types: [rust] types: [rust]
pass_filenames: false pass_filenames: false
@ -42,7 +42,7 @@ repos:
- id: docs-links - id: docs-links
name: Rustdoc broken intra-doc links name: Rustdoc broken intra-doc links
entry: bash -c 'RUSTDOCFLAGS="-D rustdoc::broken-intra-doc-links -D rustdoc::private-intra-doc-links" cargo doc --no-deps --workspace -q' entry: bash -c 'RUSTDOCFLAGS="-D rustdoc::broken-intra-doc-links -D rustdoc::private-intra-doc-links" cargo doc --no-deps --workspace --profile ci-test -q'
language: system language: system
types: [rust] types: [rust]
pass_filenames: false pass_filenames: false

View file

@ -0,0 +1,32 @@
# Generated by ore workflow generate — layer: ci-exhaustive
# Source: .ontology/workflow.ncl
# Do not edit manually — regenerate with: ore workflow generate --layer ci-exhaustive
when:
event: [push, tag, manual]
branch: main
steps:
deny-all:
image: rust:latest
commands:
- cargo install cargo-deny --locked
- cargo deny check
geiger:
image: rust:latest
commands:
- cargo install cargo-geiger --locked
- cargo geiger --all-features --all-targets
build-release-musl-x86:
image: rust:latest
commands:
- cargo install cross --locked
- cross build --target x86_64-unknown-linux-musl --release
build-sbom:
image: rust:latest
commands:
- cargo install cargo-sbom --locked
- cargo sbom

View file

@ -1,84 +1,51 @@
# Woodpecker CI Pipeline # Generated by ore workflow generate — layer: ci-standard
# Equivalent to GitHub Actions CI workflow # Source: .ontology/workflow.ncl
# Generated by dev-system/ci # Do not edit manually — regenerate with: ore workflow generate --layer ci-standard
when: when:
event: [push, pull_request, manual] event: [push, pull_request, manual]
branch:
- main
- develop
steps: steps:
# === LINTING === rust-clippy-all:
lint-rust:
image: rust:latest image: rust:latest
commands: commands:
- curl --proto '=https' --tlsv1.2 -sSf https://just.systems/install.sh | bash -s -- --to /usr/local/bin - cargo clippy --all-targets --all-features -- -D warnings
- rustup component add clippy
- cargo fmt --all -- --check
- cargo clippy --all-targets -- -D warnings
lint-bash: nickel-typecheck:
image: koalaman/shellcheck-alpine:stable
commands:
- apk add --no-cache curl bash
- find . -name '*.sh' -type f ! -path './target/*' -exec shellcheck {} +
lint-nickel:
image: rust:latest image: rust:latest
commands: commands:
- cargo install nickel-lang-cli --locked - cargo install nickel-lang-cli --locked
- find . -name '*.ncl' -type f ! -path './target/*' -exec nickel typecheck {} \; - nickel typecheck
lint-nushell: nushell-check:
image: rust:latest image: rust:latest
commands: commands:
- cargo install nu --locked - cargo install nu --locked
- find . -name '*.nu' -type f ! -path './target/*' -exec nu --ide-check 100 {} \; - find . -name '*.nu' ! -path '*/target/*' -print0 | xargs -0 -I\{\} nu --ide-check 100 \{\}
lint-markdown: nextest-ci:
image: node:alpine
commands:
- npm install -g markdownlint-cli2
- markdownlint-cli2 '**/*.md' '#node_modules' '#target'
# === TESTING ===
test:
image: rust:latest image: rust:latest
commands: commands:
- cargo test --workspace --all-features - cargo install cargo-nextest --locked
depends_on: - cargo nextest run --all-features --workspace --profile ci --cargo-profile ci
- lint-rust depends_on: ["rust-clippy-all", "nickel-typecheck", "nushell-check"]
- lint-bash environment:
- lint-nickel RUST_BACKTRACE: 1
- lint-nushell
- lint-markdown
# === BUILD === deny-subset:
build:
image: rust:latest
commands:
- cargo build --release
depends_on:
- test
# === SECURITY ===
security-audit:
image: rust:latest
commands:
- cargo install cargo-audit --locked
- cargo audit --deny warnings
depends_on:
- lint-rust
license-check:
image: rust:latest image: rust:latest
commands: commands:
- cargo install cargo-deny --locked - cargo install cargo-deny --locked
- cargo deny check licenses advisories - cargo deny check licenses advisories
depends_on:
- lint-rust docs-check:
image: rust:latest
commands:
- RUSTDOCFLAGS="-D rustdoc::broken-intra-doc-links -D rustdoc::private-intra-doc-links" cargo doc --no-deps --workspace --profile ci -q
depends_on: ["rust-clippy-all", "nickel-typecheck", "nushell-check"]
build-release-native:
image: rust:latest
commands:
- cargo build --release --workspace
depends_on: ["rust-clippy-all", "nickel-typecheck", "nushell-check"]

19
justfiles/workflow.just Normal file
View file

@ -0,0 +1,19 @@
# Generated by ore workflow generate
# Source: .ontology/workflow.ncl
# layer: ci-standard
ci-standard:
cargo clippy --all-targets --all-features -- -D warnings
cargo nextest run --all-features --workspace --profile ci --cargo-profile ci
cargo deny check licenses advisories
cargo doc --no-deps --workspace --profile ci -q
nickel typecheck
nu --ide-check 100
cargo build --release --workspace
# layer: ci-exhaustive
ci-exhaustive:
cargo deny check
cargo geiger --all-features --all-targets
cross build --target x86_64-unknown-linux-musl --release
cargo sbom

View file

@ -24,6 +24,7 @@ use ../modules/run.nu *
use ../modules/graph.nu * use ../modules/graph.nu *
use ../modules/validate.nu * use ../modules/validate.nu *
use ../modules/migrate.nu * use ../modules/migrate.nu *
use ../modules/workflow.nu *
use ../nulib/fmt.nu * use ../nulib/fmt.nu *
use ../nulib/shared.nu * use ../nulib/shared.nu *
@ -354,6 +355,48 @@ def "main mg" [action?: string] { missing-target "migrate" $action }
def "main mg l" [--fmt (-f): string = ""] { log-action "migrate list" "read"; migrate list --fmt $fmt } def "main mg l" [--fmt (-f): string = ""] { log-action "migrate list" "read"; migrate list --fmt $fmt }
def "main mg p" [--fmt (-f): string = ""] { log-action "migrate pending" "read"; migrate pending --fmt $fmt } def "main mg p" [--fmt (-f): string = ""] { log-action "migrate pending" "read"; migrate pending --fmt $fmt }
# ── Workflow ──────────────────────────────────────────────────────────────────
def "main workflow" [action?: string] { missing-target "workflow" $action }
def "main workflow validate" [] {
log-action "workflow validate" "read"
workflow validate
}
def "main workflow list" [] {
log-action "workflow list" "read"
workflow list
}
def "main workflow generate" [
--root: string
--layer: string
--provider: string
--dry-run
] {
log-action "workflow generate" "write"
if $dry_run {
workflow generate --root ($root | default "") --layer ($layer | default "") --provider ($provider | default "") --dry-run
} else {
workflow generate --root ($root | default "") --layer ($layer | default "") --provider ($provider | default "")
}
}
def "main workflow diff" [--root: string, --layer: string, --provider: string] {
log-action "workflow diff" "read"
workflow diff --root ($root | default "") --layer ($layer | default "") --provider ($provider | default "")
}
def "main wf" [action?: string] { missing-target "workflow" $action }
def "main wf v" [] { main workflow validate }
def "main wf l" [] { main workflow list }
def "main wf gen" [--dry-run, --layer: string, --provider: string] {
if $dry_run {
main workflow generate --layer ($layer | default "") --provider ($provider | default "") --dry-run
} else {
main workflow generate --layer ($layer | default "") --provider ($provider | default "")
}
}
def "main wf diff" [--layer: string, --provider: string] {
main workflow diff --layer ($layer | default "") --provider ($provider | default "")
}
# ── Validate ────────────────────────────────────────────────────────────────── # ── Validate ──────────────────────────────────────────────────────────────────
def "main validate" [target?: string] { missing-target "validate" $target } def "main validate" [target?: string] { missing-target "validate" $target }

View file

@ -0,0 +1,208 @@
# Standard validation, build, and distribution catalog for Rust/Nushell/Nickel projects.
# Workspaces import this and reference IDs in their layer declarations.
# Override or extend by merging with &: `D.validations & { my-check = { ... } }`
{
validations = {
# ── Rust linting ─────────────────────────────────────────────────────────
rust-fmt = {
id = "rust-fmt",
kind = 'Lint,
tool = "cargo",
args = ["+nightly", "fmt", "--all", "--", "--check"],
when = ["*.rs"],
fail_fast = true,
cargo_profile = "",
},
rust-clippy = {
id = "rust-clippy",
kind = 'Lint,
tool = "cargo",
args = ["clippy", "--all-targets", "--no-deps", "--profile", "clippy", "--", "-D", "warnings"],
when = ["*.rs"],
fail_fast = true,
cargo_profile = "clippy",
},
rust-clippy-all = {
id = "rust-clippy-all",
kind = 'Lint,
tool = "cargo",
args = ["clippy", "--all-targets", "--all-features", "--", "-D", "warnings"],
when = ["*.rs"],
fail_fast = true,
cargo_profile = "",
},
# ── Rust testing ─────────────────────────────────────────────────────────
nextest-ci-test = {
id = "nextest-ci-test",
kind = 'Test,
tool = "cargo-nextest",
args = ["run", "--all-features", "--workspace",
"--profile", "ci-test", "--cargo-profile", "ci-test"],
when = ["*.rs"],
fail_fast = true,
cargo_profile = "ci-test",
},
nextest-ci = {
id = "nextest-ci",
kind = 'Test,
tool = "cargo-nextest",
args = ["run", "--all-features", "--workspace",
"--profile", "ci", "--cargo-profile", "ci"],
when = ["*.rs"],
fail_fast = false, # see all failures in CI
cargo_profile = "ci",
},
# ── Security ─────────────────────────────────────────────────────────────
deny-subset = {
id = "deny-subset",
kind = 'Security,
tool = "cargo-deny",
args = ["check", "licenses", "advisories"],
when = ["Cargo.toml", "Cargo.lock"],
fail_fast = true,
cargo_profile = "",
},
deny-all = {
id = "deny-all",
kind = 'Security,
tool = "cargo-deny",
args = ["check"],
when = [],
fail_fast = true,
cargo_profile = "",
},
geiger = {
id = "geiger",
kind = 'Security,
tool = "cargo-geiger",
args = ["--all-features", "--all-targets"],
when = ["*.rs"],
fail_fast = false, # informational by default; use --forbid-only to gate
cargo_profile = "",
},
# ── Documentation ────────────────────────────────────────────────────────
docs-check = {
id = "docs-check",
kind = 'Docs,
tool = "cargo",
args = ["doc", "--no-deps", "--workspace", "--profile", "ci", "-q"],
when = ["*.rs"],
fail_fast = true,
cargo_profile = "ci",
},
# ── Compliance (ontoref-specific) ────────────────────────────────────────
docs-drift = {
id = "docs-drift",
kind = 'Compliance,
tool = "nu",
args = ["-c", "use ./reflection/modules/sync.nu; sync diff --docs --fail-on-drift"],
when = ["*.rs"],
fail_fast = true,
cargo_profile = "",
},
manifest-coverage = {
id = "manifest-coverage",
kind = 'Compliance,
tool = "nu",
args = ["--no-config-file", "-c",
"use ./reflection/modules/sync.nu *; sync manifest-check"],
when = [".ontology/*.ncl", "reflection/modes/*.ncl", "reflection/forms/*.ncl"],
fail_fast = true,
cargo_profile = "",
},
# ── Nickel / Nushell linting ─────────────────────────────────────────────
nickel-typecheck = {
id = "nickel-typecheck",
kind = 'Lint,
tool = "nickel",
args = ["typecheck"],
when = ["*.ncl"],
fail_fast = true,
cargo_profile = "",
},
nushell-check = {
id = "nushell-check",
kind = 'Lint,
tool = "nu",
args = ["--ide-check", "100"],
when = ["*.nu"],
fail_fast = true,
cargo_profile = "",
},
# ── Markdown ─────────────────────────────────────────────────────────────
markdownlint = {
id = "markdownlint",
kind = 'Lint,
tool = "markdownlint-cli2",
args = [],
when = ["*.md"],
fail_fast = true,
cargo_profile = "",
},
},
builds = {
release-native = {
id = "release-native",
kind = 'Binary,
tool = "cargo",
args = ["build", "--release", "--workspace"],
cargo_profile = "release",
target = "",
artifacts = ["target/release/"],
},
release-musl-x86 = {
id = "release-musl-x86",
kind = 'Binary,
tool = "cross",
args = ["build", "--target", "x86_64-unknown-linux-musl", "--release"],
cargo_profile = "release",
target = "x86_64-unknown-linux-musl",
artifacts = ["target/x86_64-unknown-linux-musl/release/"],
},
sbom = {
id = "sbom",
kind = 'SBOM,
tool = "cargo-sbom",
args = [],
cargo_profile = "",
target = "",
artifacts = ["sbom.json"],
},
docs-html = {
id = "docs-html",
kind = 'Docs,
tool = "cargo",
args = ["doc", "--no-deps", "--workspace"],
cargo_profile = "release",
target = "",
artifacts = ["target/doc/"],
},
},
distributions = {},
}

View file

@ -66,6 +66,11 @@
default = true, default = true,
help = "Thin bash wrapper that sets ONTOREF_ROOT and ONTOREF_PROJECT_ROOT, then delegates to ontoref entry point." }, help = "Thin bash wrapper that sets ONTOREF_ROOT and ONTOREF_PROJECT_ROOT, then delegates to ontoref entry point." },
{ type = "confirm", name = "install_workflow",
prompt = "Create .ontology/workflow.ncl (CI/build/distribution layer declaration)?",
default = true,
help = "Declares independent workflow layers (commit-fast, ci-standard, ci-exhaustive). After adoption, run: ore workflow generate" },
# ── Validation ─────────────────────────────────────────────────────────── # ── Validation ───────────────────────────────────────────────────────────
{ type = "section_header", name = "validation_header", { type = "section_header", name = "validation_header",
title = "Validation", border_top = true, border_bottom = true }, title = "Validation", border_top = true, border_bottom = true },
@ -80,7 +85,7 @@
title = "Review", border_top = true }, title = "Review", border_top = true },
{ type = "section", name = "review_note", { type = "section", name = "review_note",
content = "The generated script will:\n 1. mkdir -p .ontoref/logs .ontoref/locks (idempotent)\n 2. Copy .ontoref/config.ncl from template (if not present)\n 3. Copy .ontology/{core,state,gate}.ncl stubs (if not present)\n 4. Install scripts/ontoref wrapper (if not present)\n 5. Run nickel export on .ontology/ files to validate\n\nFiles already present are NOT overwritten." }, content = "The generated script will:\n 1. mkdir -p .ontoref/logs .ontoref/locks (idempotent)\n 2. Copy .ontoref/config.ncl from template (if not present)\n 3. Copy .ontology/{core,state,gate}.ncl stubs (if not present)\n 4. Install scripts/ontoref wrapper (if not present)\n 5. Copy .ontology/workflow.ncl stub (if install_workflow and not present)\n 6. Run nickel export on .ontology/ files to validate\n\nFiles already present are NOT overwritten." },
{ type = "confirm", name = "ready_to_generate", { type = "confirm", name = "ready_to_generate",
prompt = "Generate the adopt script?", prompt = "Generate the adopt script?",

View file

@ -0,0 +1,57 @@
{
id = "0014",
slug = "workflow-layer-model",
description = "Workflow layer model: .ontology/workflow.ncl declares independent CI/build/distribution layers. Cargo profiles ci-test (pre-commit, debug=0) and ci (CI pipeline, line-tables-only) must exist. Nextest profiles ci-test and ci must exist. Generator: ore workflow generate.",
check = {
tag = "NuCmd",
cmd = "let root = ($env.ONTOREF_PROJECT_ROOT? | default ($env.ONTOREF_ROOT? | default (pwd | path expand))); let wf = $\"($root)/.ontology/workflow.ncl\"; if not ($wf | path exists) { exit 1 }; let cargo_cfg = $\"($root)/.cargo/config.toml\"; if not ($cargo_cfg | path exists) { exit 1 }; let cargo_txt = (open --raw $cargo_cfg); let has_ci_test = ($cargo_txt | str contains \"[profile.ci-test]\"); let has_ci = ($cargo_txt | str contains \"[profile.ci]\"); let nextest_cfg = $\"($root)/.config/nextest.toml\"; if not ($nextest_cfg | path exists) { exit 1 }; let nextest_txt = (open --raw $nextest_cfg); let nx_ci_test = ($nextest_txt | str contains \"[profile.ci-test]\"); let nx_ci = ($nextest_txt | str contains \"[profile.ci]\"); if $has_ci_test and $has_ci and $nx_ci_test and $nx_ci { exit 0 } else { exit 1 }",
expect_exit = 0,
},
instructions = "
Adopt the workflow layer model:
1. Create .ontology/workflow.ncl declaring your CI layers.
Use the template at reflection/templates/ontology/workflow.ncl
or copy from an existing workspace.
2. Add cargo profiles to .cargo/config.toml:
[profile.ci-test]
# Pre-commit: no debug info, no incremental — shared .rlib between test + docs hooks
inherits = \"test\"
debug = 0
incremental = false
[profile.ci]
# CI pipeline: line-tables-only for actionable backtraces on remote failures
inherits = \"test\"
debug = \"line-tables-only\"
incremental = false
3. Add nextest profiles to .config/nextest.toml:
[profile.ci-test]
fail-fast = true
retries = 0
[profile.ci]
fail-fast = false
retries = 1
4. Generate CI artifacts from the declaration:
ore workflow generate
This writes:
- .woodpecker/ci.yml (from layers with provider Woodpecker + trigger OnPR)
- .woodpecker/ci-exhaustive.yml (from layers with trigger OnMainMerge)
- justfiles/workflow.just (from layers with provider Justfile)
Pre-commit hooks are printed for manual merge into .pre-commit-config.yaml.
5. Verify:
ore workflow validate
ore workflow list
",
}

View file

@ -0,0 +1,498 @@
#!/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 { [] })
}

View file

@ -0,0 +1,100 @@
let layer_trigger = [|
'OnCommit, # pre-commit (git) / jjw pre-op check (jj)
'OnPush, # pre-push (git) / rad patch submit (radicle)
'OnPR, # pull_request on remote CI
'OnMainMerge, # push to trunk/main branch
'OnTag, # semver tag event
'OnManual, # explicit user/agent trigger
|] in
let validation_kind = [| 'Lint, 'Test, 'Security, 'Compliance, 'Docs |] in
let build_kind = [| 'Binary, 'Library, 'Container, 'Docs, 'SBOM |] in
let distribution_kind = [| 'CargoRegistry, 'ContainerRegistry, 'Package, 'Artifact |] in
# Validation spec — tool invocation, VCS/provider agnostic.
# The generator translates this into provider-specific syntax.
let validation_spec = {
id | String | default = "",
kind | validation_kind | default = 'Lint,
tool | String | default = "",
args | Array String | default = [],
when | Array String | default = [], # glob patterns; empty = always run
fail_fast | Bool | default = true,
cargo_profile | String | default = "", # "" if not applicable
} in
# Build spec — artifact production, VCS/provider agnostic.
let build_spec = {
id | String | default = "",
kind | build_kind | default = 'Binary,
tool | String | default = "",
args | Array String | default = [],
cargo_profile | String | default = "",
target | String | default = "", # cargo target triple; "" = native
artifacts | Array String | default = [], # expected output paths
} in
# Distribution spec — where to send artifacts after build.
let distribution_spec = {
id | String | default = "",
kind | distribution_kind | default = 'Artifact,
tool | String | default = "",
args | Array String | default = [],
destination | String | default = "",
} in
# A workflow layer is an independent, self-contained set of operations.
# Layers do NOT depend on each other — they form a set, not a chain.
# A workspace may activate any combination of layers independently.
let workflow_layer = {
id | String | default = "",
trigger | layer_trigger | default = 'OnManual,
validations | Array String | default = [], # IDs from the active validations catalog
builds | Array String | default = [], # IDs from the active builds catalog
distributions | Array String | default = [], # IDs from the active distributions catalog
# Provider implementations enabled for this layer.
# Each provider generates a different artifact from the same layer declaration.
# Use the ProviderImpl helpers below to construct typed provider records.
providers | Array { tag | String, .. } | default = [],
} in
# Complete workflow declaration for a workspace.
# Workspaces import defaults/workflow.ncl and extend/override as needed.
let workflow_declaration = {
layers | Array workflow_layer | default = [],
validations | { _ | validation_spec } | default = {},
builds | { _ | build_spec } | default = {},
distributions | { _ | distribution_spec } | default = {},
} in
# ── Provider constructor helpers ──────────────────────────────────────────────
# Use these to build the providers array in WorkflowLayer.
let mk_pre_commit = fun s => { tag = "PreCommit", stage = s } in
let mk_woodpecker = fun f => { tag = "Woodpecker", file = f } in
let mk_github_actions = fun f => { tag = "GithubActions", file = f } in
let mk_justfile = fun r => { tag = "Justfile", recipe = r } in
let mk_jjw_wrapper = { tag = "JjwWrapper" } in
{
# Types / contracts — use as field contracts: `field | W.WorkflowLayer`
LayerTrigger = layer_trigger,
ValidationKind = validation_kind,
BuildKind = build_kind,
DistributionKind = distribution_kind,
ValidationSpec = validation_spec,
BuildSpec = build_spec,
DistributionSpec = distribution_spec,
WorkflowLayer = workflow_layer,
WorkflowDeclaration = workflow_declaration,
# Provider constructors — use in `providers = [W.pre_commit "pre-commit", ...]`
pre_commit = mk_pre_commit,
woodpecker = mk_woodpecker,
github_actions = mk_github_actions,
justfile = mk_justfile,
jjw_wrapper = mk_jjw_wrapper,
}

View file

@ -73,7 +73,20 @@ if ($wrapper_dest | path exists) {
} }
{% endif %} {% endif %}
# ── 5. Validate .ontology/ ──────────────────────────────────────────────────── # ── 5. .ontology/workflow.ncl ────────────────────────────────────────────────
{% if install_workflow %}
let wf_dest = $"($project_dir)/.ontology/workflow.ncl"
if ($wf_dest | path exists) {
print_skip ".ontology/workflow.ncl"
} else {
let wf_src = $"($ontoref_dir)/templates/ontology/workflow.ncl"
$wf_src | open --raw | save $wf_dest
print_ok ".ontology/workflow.ncl (stub — edit layers, then: ore workflow generate)"
}
{% endif %}
# ── 6. Validate .ontology/ ────────────────────────────────────────────────────
{% if validate_after %} {% if validate_after %}
print "" print ""

View file

@ -0,0 +1,70 @@
let W = import "../reflection/schemas/workflow.ncl" in
let D = import "../reflection/defaults/workflow.ncl" in
# Workflow declaration for {{ project_name }}.
# Each layer is independent — layers form a set, not a chain.
# Activate a layer by adding a provider; comment out providers to disable generation.
#
# Run after editing: ore workflow generate
# Preview changes: ore workflow diff
{
layers = [
# ── Commit-fast: local pre-commit checks ────────────────────────────────────
# Generates entries for .pre-commit-config.yaml (manual merge required).
{
id = "commit-fast",
trigger = 'OnCommit,
validations = [
"rust-fmt",
"rust-clippy",
"nextest-ci-test",
"deny-subset",
"docs-drift",
"manifest-coverage",
],
builds = [],
distributions = [],
providers = [W.pre_commit "pre-commit"],
},
# ── CI-standard: push/PR pipeline ───────────────────────────────────────────
# Generates .woodpecker/ci.yml (or equivalent for other providers).
{
id = "ci-standard",
trigger = 'OnPR,
validations = [
"rust-clippy-all",
"nextest-ci",
"deny-subset",
"docs-check",
"nickel-typecheck",
"nushell-check",
],
builds = ["release-native"],
distributions = [],
providers = [
W.woodpecker ".woodpecker/ci.yml",
W.justfile "ci-standard",
],
},
# ── CI-exhaustive: main/tag pipeline ────────────────────────────────────────
# Generates .woodpecker/ci-exhaustive.yml. Disable if not needed.
# {
# id = "ci-exhaustive",
# trigger = 'OnMainMerge,
# validations = ["deny-all", "geiger"],
# builds = ["release-musl-x86", "sbom"],
# distributions = [],
# providers = [
# W.woodpecker ".woodpecker/ci-exhaustive.yml",
# W.justfile "ci-exhaustive",
# ],
# },
],
validations = D.validations,
builds = D.builds,
distributions = {},
}