ontoref-code/domains/rustelo/commands.nu
2026-07-18 00:57:58 +01:00

375 lines
20 KiB
Text

#!/usr/bin/env nu
# domains/rustelo/commands.nu — CLI dispatch for RusteloApp projects.
# Delegates to ontoref --project-root <rustelo> 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/<slug> -> 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/<lang>/**.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+"(?<component>\w+)"\s*\{(?<body>[^}]*)\}'
| each {|r|
let paths = ($r.body | parse --regex '"(?<p>/[^"]*)"' | 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/<lang>.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*\[(?<l>[^\]]*)\]' | get l)
if ($m | is-empty) { ["en"] } else { ($m | first | parse --regex '"(?<x>[a-z]{2})"' | get x) }
} else { ["en"] }
# ── What the check must actually establish ──────────────────────────────────
#
# An earlier version of this asserted only that `<page_id>-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/<page_id>.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 = \[(?<p>[^\]]*)\]' | get p)
if ($m | is-empty) { [] } else { ($m | first | parse --regex '"(?<x>[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)^(?<k>[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 }
}