#!/usr/bin/env nu # domains/rustelo/commands.nu — CLI dispatch for RusteloApp projects. # Delegates to ontoref --project-root for domain-level operations. # publish-contract / probe deliver the content-publisher consumption contract # (ADR-046 view discipline, ADR-056 verification) to any onboarded RusteloApp. # Locate the site workspace (dir containing site/content) under a project root. def site-workspace [root: string]: nothing -> string { if ([$root, "site", "content"] | path join | path exists) { $root } else if ([$root, "code", "site", "content"] | path join | path exists) { $"($root)/code" } else { "" } } def main [cmd?: string, ...rest: string] { let domain_root = (^ontoref project root rustelo | str trim) match ($cmd | default "") { "layers" => { with-env { ONTOREF_PROJECT_ROOT: $domain_root } { ^ontoref describe features } }, "plugins" => { print " (rustelo plugin registry — not yet implemented)" }, # Crosses the levels: routes come from the FRAMEWORK/BUILD level, the FTL and the content # tree from THIS instance. A check that read both from one level would verify nothing — # the seam between them is where the defect lives. "routes" => { routes $domain_root ($env.ONTOREF_PROJECT_ROOT? | default (pwd | path expand)) ("--check" in $rest) }, "wasm" => { print " (rustelo WASM targets — not yet implemented)" }, "publish-contract" => { publish-contract $domain_root }, "probe" => { probe }, _ => { print " rustelo: layers | plugins | routes | wasm | publish-contract | probe" print " publish-contract the 6-answer content-publisher contract (+ oracle), from the producer" print " probe run the end-to-end publish->activate->index oracle in THIS project" }, } } # Print the producer-declared content-publisher contract. Resolved from the # rustelo project root — works from any RusteloApp, no copy. def publish-contract [domain_root: string] { let ncl = $"($domain_root)/domains/rustelo/ontology/publishing-contract.ncl" if not ($ncl | path exists) { print $" ✗ contract not found at ($ncl)" return } let res = (do { ^nickel export --import-path ($ncl | path dirname) $ncl } | complete) if $res.exit_code != 0 { print " ✗ contract export failed:" print $res.stderr return } let c = ($res.stdout | from json) print "" print $" (ansi cyan_bold)Rustelo content-publisher contract(ansi reset) [profile: ($c.profile)]" print $" (ansi green)activate(ansi reset) ($c.activate_cmd)" print $" (ansi green)serve(ansi reset) ($c.serve_cmd)" print $" (ansi green)index(ansi reset) ($c.content_root)" for q in $c.questions { let tag = if $q.side == "ContentOnly" { $"(ansi green)content-only(ansi reset)" } else { $"(ansi yellow)recompile(ansi reset)" } print "" print $" (ansi white_bold)◆ ($q.id)(ansi reset) [($tag)]" print $" (ansi default_dimmed)Q(ansi reset) ($q.question)" print $" (ansi default_dimmed)A(ansi reset) ($q.answer)" print $" (ansi magenta)oracle(ansi reset) ($q.oracle)" } let gaps = ($c.known_gaps? | default []) if ($gaps | length) > 0 { print "" print $" (ansi yellow_bold)Known gaps(ansi reset) — declared producer-side limitations, plan around them" for g in $gaps { print $" (ansi yellow)▲ ($g.id)(ansi reset) [($g.side)/($g.status)]" print $" (ansi default_dimmed)area(ansi reset) ($g.area)" print $" (ansi default_dimmed)symptom(ansi reset) ($g.symptom)" print $" (ansi magenta)oracle(ansi reset) ($g.oracle)" } } print "" } # Run the end-to-end binding oracle in the CURRENT RusteloApp: publish a trivial # item, activate (content_processor), assert it entered the index, clean up. # Witnessed verification (ADR-056) — proves the contract before generating pages. def probe [] { let root = ($env.ONTOREF_PROJECT_ROOT? | default (pwd | path expand)) let ws = (site-workspace $root) if ($ws | is-empty) { print $" ✗ no site/content found under ($root) (looked at site/ and code/site/) — not a content-only RusteloApp" return } if (which content_processor | is-empty) { print " ✗ content_processor not on PATH — install the rustelo content tooling" return } let probe_md = $"($ws)/site/content/blog/en/getting-started/_ontoref-probe.md" let probe_dir = ($probe_md | path dirname) mkdir $probe_dir "---\nid: \"_ontoref-probe\"\ntitle: \"Ontoref Probe\"\nslug: \"_ontoref-probe\"\nexcerpt: \"Auto-generated contract probe.\"\nauthor: \"ontoref\"\ndate: \"2026-01-01\"\npublished: true\ncategory: \"getting-started\"\ntags: [\"probe\"]\n---\n\n# Ontoref Probe\n\nWitnessed publish->activate->index oracle.\n" | save -f $probe_md cd $ws let act = (do { ^content_processor --content-type blog --language en } | complete) let html = $"($ws)/site/r/blog/en/getting-started/_ontoref-probe.html" let idx = $"($ws)/site/r/blog/en/getting-started/index.json" let html_ok = ($html | path exists) let idx_ok = if ($idx | path exists) { (open --raw $idx | str contains "_ontoref-probe") } else { false } # Clean up the probe artifacts and re-index to drop it from the index. rm -f $probe_md $html $"($ws)/site/r/blog/en/getting-started/_ontoref-probe.json" do { ^content_processor --content-type blog --language en } | complete | ignore print "" if ($act.exit_code == 0) and $html_ok and $idx_ok { print $" (ansi green_bold)GREEN(ansi reset) publish->activate->index oracle passed" print $" item indexed at site/r/blog/en/getting-started/ and present in index.json" print $" next: serve \(rustelo-htmx-server\) and GET /blog/getting-started/ -> expect HTTP 200" } else { print $" (ansi red_bold)RED(ansi reset) oracle failed — activate exit=($act.exit_code), html=($html_ok), in_index=($idx_ok)" if $act.exit_code != 0 { print $act.stderr } } print "" } # ── routes — the route manifest, and the witness across the three levels ────── # # This command was DECLARED in domain.ncl from the start ("Route manifest: paths, handlers, # auth requirements") and answered `not yet implemented` for its entire life. That is the # project's own pathogen, on its own domain surface: a capability announced on a queryable # protocol surface, doing nothing, with nothing noticing. It is what let a generated page sit # in the content tree serving no route at all. # # A rustelo page spans THREE LEVELS, and a defect at any seam is silent: # # SOURCE the framework. `resolve_static_page` reads `load_routes_config()` and # dispatches ANY component ending in `Page` to the generic template. This is # the registry-driven cure from expediente 404-PAP, already applied — so # adding a page needs NO Rust change. Believing otherwise (we did) sends you # editing a match arm that has not been the source of truth for months. # IMPLEMENTATION `site/config/routes.ncl` — the declared routes. BAKED at build time. # CONTENT the instance: `site/i18n/locales//**.ftl` carries the page's TEXT. # # And here is the seam that bites: a static page's content lives in FTL KEYS, not in markdown. # `static.j2` renders `texts[page_id ~ "-page-title"] | default(page_id)`. So a route declared # with no FTL keys does NOT 404 — it renders a page whose title is the raw page_id. A broken # page that returns 200. Nothing in the stack complains, which is why six of them are live. # # ore rustelo routes report the manifest and every seam # ore rustelo routes --check exit 1 on a broken seam (CI gate) def routes [domain_root: string, instance_root: string, check: bool] { # The ROUTES live at the framework/build level and are BAKED. The FTL and the content tree # live in THIS instance. The seam between the two levels is exactly where a page dies, so the # check must span both — an instance verifying only itself would report clean over a route it # does not own, and a framework verifying only itself would never see the orphan markdown. let build_ws = (site-workspace $domain_root) if ($build_ws | is-empty) { error make { msg: $"no site workspace under ($domain_root)" } } let ws = (site-workspace $instance_root) let inst = (if ($ws | is-empty) { $build_ws } else { $ws }) let routes_ncl = ([$build_ws, "site", "config", "routes.ncl"] | path join) if not ($routes_ncl | path exists) { error make { msg: $"routes.ncl not found at ($routes_ncl)" } } # The declared routes. Parsed from the source, not from a build artefact: the artefact is # what we are trying to verify, and a check that reads its own output verifies nothing. let raw = (open --raw $routes_ncl) let declared = ( $raw | parse --regex '(?s)make_route\s+"(?\w+)"\s*\{(?[^}]*)\}' | each {|r| let paths = ($r.body | parse --regex '"(?

/[^"]*)"' | get p) { component: $r.component, paths: $paths } } ) # `AboutUsPage` → `about-us`. Mirrors kebab_case() in pages_htmx/src/lib.rs. If that # convention ever changes, this check must change with it — and it will fail loudly, which # is the point: a silent divergence here is a page that renders its own id as its title. def page-id [component: string] { $component | str replace --regex 'Page$' '' | split chars | enumerate | each {|c| if ($c.item =~ '[A-Z]') and ($c.index > 0) { $"-($c.item | str downcase)" } else { $c.item | str downcase } } | str join "" } mut findings = [] # ── Routes the CONSUMER contributes ────────────────────────────────────────── # `merge_site_routes` (rustelo_core_lib) lets a site add its own per-language routes at runtime # from `site/config/routes/.toml`, merged over the image's. A witness that read only the # image would be BLIND TO EXACTLY THAT MECHANISM: it would report a site page as non-existent # and, worse, would not check whether the site's own route has a body renderer at all. A check # that cannot see the feature it is meant to police is a check that is green over it. # # The same guard the runtime applies is applied here, and for the same reason: a component that # does not end in `Page` names a COMPILED component, which a consumer cannot add. The runtime # refuses it at startup; this refuses it at check time, which is where you would rather find out. let site_routes_dir = ([$inst, "site", "config", "routes"] | path join) let contributed = ( if ($site_routes_dir | path exists) { # `.ncl` AND `.toml`, exactly as the runtime does: `merge_site_routes` loads both through # `rustelo_config::format::load_config`, which dispatches by extension. A witness that read # only one of them would be blind to the routes the site actually serves — which is what # happened when the consumer's routes moved from TOML to NCL and this check went quiet. glob $"($site_routes_dir)/*.{ncl,toml}" | each {|f| let lang = ($f | path basename | str replace --regex '\.(ncl|toml)$' '') let cfg = ( if ($f | str ends-with ".ncl") { let r = (do { ^nickel export $f } | complete) if $r.exit_code == 0 { ($r.stdout | from json) } else { { routes: [] } } } else { try { open --raw $f | from toml } catch { { routes: [] } } } ) ($cfg.routes? | default []) | each {|r| { component: ($r.component? | default ""), paths: [($r.path? | default "")], lang: $lang, site: true } } } | flatten } else { [] } ) for c in $contributed { if not ($c.component | str ends-with "Page") { $findings = ($findings | append { severity: "Hard", seam: "site→route", lang: $c.lang, what: $"($c.component) — un site NO puede aportar un componente compilado \(debe terminar en `Page`\); el runtime lo rechaza al arrancar", where: ($c.paths | first), }) } } let declared = ($declared | append ($contributed | where {|c| $c.component | str ends-with "Page" } | each {|c| { component: $c.component, paths: $c.paths } })) let pages = ($declared | where {|r| $r.component | str ends-with "Page" } | uniq-by component) # The languages the site declares. One source, and rustelo already had this right. let site_ncl = ([$build_ws, "site", "config", "site.ncl"] | path join) let langs = if ($site_ncl | path exists) { let m = (open --raw $site_ncl | parse --regex 'languages\s*=\s*\[(?[^\]]*)\]' | get l) if ($m | is-empty) { ["en"] } else { ($m | first | parse --regex '"(?[a-z]{2})"' | get x) } } else { ["en"] } # ── What the check must actually establish ────────────────────────────────── # # An earlier version of this asserted only that `-page-title` existed. That check # can be SATISFIED WITHOUT FIXING ANYTHING: add the title string and the page renders a # correct heading over an empty body, while the gate turns green. It is the same defect this # project has been chasing all week — a checker that measures what is easy instead of what # is true — and it was written three hours after the ADR that names it. # # What a page actually needs is a RENDERER FOR ITS BODY. `static_page::render` picks # `pages/.j2` when it exists and falls back to the generic `static.j2`, which reads # exactly three keys: title, subtitle, description. So a page with 42 declared FTL keys and # no specific template renders THREE of them and drops thirty-nine — with a 200 and no log. # # This is the Leptos→htmx migration gap. Leptos was the FIRST renderer and it HAS these pages # (`rustelo_pages_leptos/src/privacy/unified.rs` renders the whole policy from those keys). # htmx came second and never ported them. Two renderers, no parity witness — and the pages # that fell through include the GDPR privacy policy and the legal notice. # Templates come from TWO levels and the check must see both, or it lies. `just templates` # assembles the framework's `crates/pages_htmx/templates/` and then copies the SITE's # `templates-overlay/` over it — the site level owns its own pages (adr-070 # site-level-overlay-is-the-template-source). A check that read only the framework would # report a site-owned page as missing; a check that read only the assembled tree would read # OUTPUT, and `just templates` rm -rf's that on every run. Read the two SOURCES. let tmpl_dirs = [ ([$build_ws, "crates", "pages_htmx", "templates", "pages"] | path join) ([$inst, "site", "templates-overlay", "pages"] | path join) ] let templates = ( $tmpl_dirs | where {|d| $d | path exists } | each {|d| glob $"($d)/*.j2" | each {|f| $f | path basename | str replace --regex '\.j2$' '' } } | flatten | uniq ) # static.j2 delegates these two classes to partials, so they DO get a body. let static_j2 = ([$build_ws, "crates", "pages_htmx", "templates", "pages", "static.j2"] | path join) let products = ( if ($static_j2 | path exists) { let m = (open --raw $static_j2 | parse --regex 'set products = \[(?

[^\]]*)\]' | get p) if ($m | is-empty) { [] } else { ($m | first | parse --regex '"(?[a-z]+)"' | get x) } } else { [] } ) const VIA_PARTIAL = ["login"] # The three keys the generic template reads. Anything beyond them is written and dropped. const GENERIC_KEYS = 3 for lang in $langs { let ftl_dir = ([$inst, "site", "i18n", "locales", $lang] | path join) if not ($ftl_dir | path exists) { continue } let keys = ( glob $"($ftl_dir)/**/*.ftl" | each {|f| open --raw $f | parse --regex '(?m)^(?[a-z0-9-]+)\s*=' | get k } | flatten ) for p in $pages { let pid = (page-id $p.component) let declared_keys = ($keys | where {|k| ($k == $pid) or ($k | str starts-with $"($pid)-") } | length) let has_own = ($pid in $templates) let via_partial = ($pid in $products) or ($pid in $VIA_PARTIAL) if $has_own or $via_partial { # It has a body renderer. The only remaining question is whether it has a title at all. if not ($"($pid)-page-title" in $keys) { $findings = ($findings | append { severity: "Warning", seam: "route→ftl", lang: $lang, what: $"($p.component) → sin `($pid)-page-title` \(renderiza el page_id crudo\)", where: ($p.paths | first | default "?"), }) } } else if $declared_keys > $GENERIC_KEYS { # THE ONE THAT MATTERS. Content written, and thrown away on every request. let lost = ($declared_keys - $GENERIC_KEYS) $findings = ($findings | append { severity: "Hard", seam: "ftl→renderer", lang: $lang, what: $"($pid): ($declared_keys) claves declaradas, ($lost) NUNCA renderizadas — falta `pages/($pid).j2`", where: ($p.paths | first | default "?"), }) } else { # A route with no content at all. Not a lie — just empty. $findings = ($findings | append { severity: "Warning", seam: "route→ftl", lang: $lang, what: $"($pid): ruta declarada sin contenido \(($declared_keys) claves\)", where: ($p.paths | first | default "?"), }) } } } # ── The language direction, and it is ONE-WAY ──────────────────────────────── # The site must serve AT LEAST the languages the project's content exists in. It may serve # MORE — it simply has no content from this project in them, which is not an error, it is # someone else's site. # # The reverse check would be a disaster and was nearly written: `site.ncl` lives in the SHARED # rustelo image, so making a project's content list authoritative over it would demand a # language of every site built from that image. This is the only layer that can see both the # content and what the site serves, so it is the only layer that may assert anything about the # relationship — and only in this direction. let reg = ([$instance_root, ".ontoref", "ontology", "lexicon.ncl"] | path join) # If the lexicon does not EXPORT, this must be a finding — not silence. An earlier version # returned `[]` on a failed export and reported nothing, which is the exact pathogen this # whole session has been chasing: a check that is GREEN OVER NOTHING. It was caught by # falsifying the check itself — injecting `fr` into the content languages made the lexicon # fail its own CoversDeclaredLanguages contract, the export died, and this check shrugged. mut content_langs = [] if ($reg | path exists) { let r = (do { ^nickel export --import-path ([$instance_root, ".ontoref"] | path join) $reg } | complete) if $r.exit_code == 0 { $content_langs = (($r.stdout | from json).languages) } else { $findings = ($findings | append { severity: "Hard", seam: "content→site", lang: "—", what: "el léxico del contenido NO EXPORTA — no se puede saber en qué idiomas existe", where: $reg, }) } } for cl in $content_langs { if not ($cl in $langs) { $findings = ($findings | append { severity: "Hard", seam: "content→site", lang: $cl, what: $"el contenido existe en `($cl)` y el site NO lo sirve \(site.ncl: ($langs | str join ', ')\)", where: $site_ncl, }) } } print $"rutas declaradas: ($declared | length) · páginas \(*Page\): ($pages | length) · idiomas: ($langs | str join ', ')" print $" rutas ← ($routes_ncl)" print $" ftl+md ← ($inst)/site/" print $"fuente: `resolve_static_page` resuelve desde el registro — añadir una página NO toca Rust" print "" if ($findings | is-empty) { print "✓ las tres capas coinciden: ruta → page_id → FTL, y sin markdown huérfano." } else { let hard = ($findings | where severity == "Hard") print $"($hard | length) Hard · (($findings | length) - ($hard | length)) aviso\(s\)" print ($findings | select severity seam lang what | sort-by severity seam) } if $check and (($findings | where severity == "Hard" | length) > 0) { exit 1 } }