375 lines
20 KiB
Text
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 }
|
|
}
|