#!/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 — generate one layer # workflow generate --provider — 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]: nothing -> string { $args | str join ' ' } # Build a tool invocation string from tool name + args, trimmed. def tool-cmd [tool: string, args: list]: 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 { [] }) }