Los casos diagnosticaban y no exigían. Cada uno cerraba con un veredicto —«un contrato que no
se aplica no es un contrato: es un comentario»— y esa lección no llegaba a ninguna parte: los
nueve apuntaban `related_modes` al modo que ESCRIBE expedientes. Un bucle circular. La serie
denunciaba el contrato desconectado y ella misma lo era.
El esquema ahora lo impide:
lesson_codified_in pares what/where con gramática de punteros verificados
(adr:NNN/id · qa:id · mode:id/paso · gate:just X · test:ruta · contract:ruta#campo)
lesson_debt lo que nada exige todavía, con la dirección de quien lo saldaría
LessonAccounted contrato que RECHAZA el caso que no llena ninguno de los dos.
Visto rechazar antes de rellenar nada. Confesar cuesta (adr-071).
replay la sesión repetida con protocolo: el prompt literal recibido (con su
procedencia declarada — una reconstrucción sin fuente es ficción), el
prompt corregido, las microtareas con su oráculo NOMBRADO, la puerta
antes de delegar y el disparador de adr?.
Contabilizados los diez casos × 2 idiomas. Cuatro llevan replay. El reparto es el hallazgo:
las lecciones que dolieron en MECANISMO acabaron en constraints de ADR; las que dolieron en
CONDUCTA DEL OPERADOR quedaron en deuda. Esa mitad era justo la que faltaba.
Nuevo: /docs/expedientes/protocolo.html y /docs/case-files/protocol.html — el protocolo de
sesión, PROYECTADO de los replays (gen-protocolo.nu, con --check en la cadena). No es una
tarjeta del grid: el grid indexa CASOS, y una proyección archivada entre las instancias de las
que se proyecta es el mismo error de categoría que un esquema archivado entre ellas. Lleva las
cuatro caras de una sola regla —el agente nunca es su propio oráculo— y la deuda viva de la
serie, que se recalcula sola: cuando una ⊘ se salda en su caso, desaparece de la página.
Nuevo expediente 8/0 — la cura que no volvió a casa (ES+EN, 'clinical). Las ocho líneas que
cerraron el caso fundacional existen, probadas y sirviendo, y no habían llegado a ninguna
parte: ni al commit de su instancia (donde alguien siguió añadiendo kinds A MANO hasta siete),
ni a las dos plantillas de las que nace cada sitio. 23 constraints en 8 ADRs, 0 ejecutadas.
Y su punto de abandono: el arquitecto recomendó borrar el atajo con «prueba empírica» construida
sobre dos árboles distintos — habría roto 6 de 8 rutas respondiendo 200. Lo paró el testigo que
el propio encargo exigía. La puerta cazó al arquitecto.
Deuda corregida en 404-PAP y 404-PAP-bis: decían que faltaban tres cosas cuando faltaba una.
El backport está hecho y la constraint escrita; sólo falta que algo la ejecute. Un howto no
expira solo — éste caducó en dos horas.
Y el mensaje de la puerta de ilustración daba rutas que no resuelven desde donde la puerta
corre. Corregido: las rutas son de la raíz de la constelación, y el conversor NO reescala.
Verificado: 22 casos reproducen · completos en en, es · servidos · informe reproduce ·
parity 11×2 · protocolo-check reproduce · oráculo de cobertura ALL PASS.
319 lines
16 KiB
Text
319 lines
16 KiB
Text
#!/usr/bin/env nu
|
||
|
||
# Project the typed expediente CASEs (.ncl) → the standalone informe, one per locale:
|
||
# site/public/images/expedientes/<lang>/expedientes.html
|
||
#
|
||
# The informe was hand-kept HTML with its data inlined as a JS array, while the ARTICLES
|
||
# were already projected from the .ncl. Same cases, two hands — so they drifted: deriva's
|
||
# ledger carried 6 rows in the article and 8 here, and `espejo` never made it in at all,
|
||
# which left its own article linking to an anchor that did not exist. One source now; the
|
||
# viewer is a projection like everything else, and `just informe-check` says so.
|
||
#
|
||
# The template (scripts/build/informe-template.html) owns the CSS, the chrome and the
|
||
# render() function. This script owns only the data: it composes each CASE with the
|
||
# locale × skin vocabulary into the shape render() already reads.
|
||
#
|
||
# Usage:
|
||
# nu scripts/build/gen-informe.nu # every locale
|
||
# nu scripts/build/gen-informe.nu --check # assert reproducible, write nothing
|
||
|
||
use ./expediente-vocab.nu [VOCAB]
|
||
|
||
def esc []: string -> string {
|
||
$in | str replace --all '&' '&' | str replace --all '<' '<' | str replace --all '>' '>'
|
||
}
|
||
|
||
# ── ONE FIELD, TWO DESTINATIONS, ONE TYPOGRAPHY ──────────────────────────────────────────────
|
||
#
|
||
# A skin's prose (`weapon_after`, `verdict_prose`, an alibi…) is written ONCE and read TWICE: the
|
||
# article renders it as Markdown, and this viewer injects it as HTML with `innerHTML`. So every
|
||
# `**bold**`, every `[link](url)` and every `` `code` `` an author wrote for the article was
|
||
# arriving at the viewer AS LITERAL CHARACTERS — asterisks and brackets on the page.
|
||
#
|
||
# It was never true of one case. It was true of the SERIES: 11 bold/link leaks and 70 raw
|
||
# backticks across five published cases, in both languages, for as long as the viewer has existed.
|
||
# Nobody saw it because nobody looked at the viewer with the question "is this text, or is this
|
||
# markup?" — and no gate could ask it, because no gate read the output at all.
|
||
#
|
||
# The subset is deliberately tiny, and it is the subset the authors actually use. Anything else
|
||
# stays literal, and the gate at the end of this file REFUSES to publish a viewer that still
|
||
# carries markdown syntax — because a converter nobody checks is the next silent drift.
|
||
# Escapa `<` y `>` DENTRO de los spans de código ya envueltos. Fuera de ellos no se toca nada:
|
||
# los campos *_html llevan HTML por contrato y son otra cosa.
|
||
def escape-code-spans []: string -> string {
|
||
$in | str replace --all --regex '<span class="mono">([^<]*)</span>' '<span class="mono">${1}</span>'
|
||
| str replace --all --regex '<span class="mono">([^<]*)<([a-zA-Z/][^>]*)>([^<]*)</span>' '<span class="mono">${1}<${2}>${3}</span>'
|
||
}
|
||
|
||
def md []: string -> string {
|
||
$in
|
||
| str replace --all --regex '\*\*([^*]+)\*\*' '<b>${1}</b>'
|
||
| str replace --all --regex '\[([^\]]+)\]\((https?://[^)]+)\)' '<a href="${2}" target="_blank" rel="noopener">${1}</a>'
|
||
# ESCAPAR DENTRO DEL SPAN DE CÓDIGO. Un `<head>` citado como código se envolvía en <span> y
|
||
# entraba en la página COMO ETIQUETA: el navegador se encontraba un <head> dentro del <body> y
|
||
# el resto del artículo dejaba de verse. El texto estaba en el HTML —47.561 caracteres— y no se
|
||
# veía ni uno. Envolver no es escapar, y un conversor que no escapa es un inyector.
|
||
| str replace --all --regex '`([^`]+)`' '<span class="mono">${1}</span>'
|
||
| escape-code-spans
|
||
}
|
||
|
||
def ledger [rows: list]: nothing -> list {
|
||
# Ledger cells reach the viewer through innerHTML like every other field, and they carry
|
||
# backticked identifiers ("the `developer` door"). Same conversion, same reason.
|
||
$rows | each {|r| [($r.label | md), ($r.value | md), (if $r.emphasis { 1 } else { 0 })] }
|
||
}
|
||
|
||
# The renderer wraps nothing in quotes; the old hand-kept data carried the curly quotes
|
||
# inside each alibi. Add them here so the .ncl stores the words, not the typography.
|
||
def quoted [xs: list]: nothing -> list {
|
||
$xs | each {|a| $"“($a)”" }
|
||
}
|
||
|
||
def skin-data [c: record, sk: record, lang: string, skin: string]: nothing -> record {
|
||
let v = ($VOCAB | get $lang)
|
||
let iv = ($v.skins | get $skin | get informe)
|
||
{
|
||
kicker: ($sk.kicker? | default $iv.kicker)
|
||
h1: [$sk.h1_lead, $sk.h1_red]
|
||
logline: ($sk.logline | md)
|
||
meta: [$iv.meta_case, $iv.meta_class, $sk.victim, $"($v.md.status_label): ($iv.status_word)"]
|
||
# The exhibit label is half vocabulary ("Exhibit A — ") and half case ("El cuerpo").
|
||
ex: ($sk.exhibits | enumerate | each {|e| $"($iv.exhibit_prefix | get $e.index)($e.item)" })
|
||
h2: ($sk.headings | each {|x| $x | md })
|
||
lead2: ($sk.lead_cost | md)
|
||
lead3: ($sk.lead_suspects | md)
|
||
weaponCap: ($sk.weapon_caption | md)
|
||
weaponAfter: ($sk.weapon_after | md)
|
||
fixCap: ($sk.fix_caption | md)
|
||
fixAfter: ($sk.fix_after | md)
|
||
verdictLead: ($sk.verdict_lead | md)
|
||
no: $iv.suspect_noun
|
||
stamp: [$iv.stamp_lead, $sk.stamp]
|
||
alibis: (quoted ($sk.alibis | each {|x| $x | md }))
|
||
tags: $sk.suspect_tags
|
||
footL: $"($iv.foot_left) ($c.case_no)"
|
||
footR: $sk.foot_right
|
||
# The replay/lesson headings are the ARTICLE's per-skin strings, reused: one heading
|
||
# per skin × locale, or the two surfaces drift apart on what the section is called.
|
||
replayH: ($v.skins | get $skin | get md | get replay)
|
||
lessonH: ($v.skins | get $skin | get md | get lesson)
|
||
}
|
||
}
|
||
|
||
def case-data [c: record, lang: string]: nothing -> record {
|
||
let v = ($VOCAB | get $lang)
|
||
let n = ($c.suspects | length)
|
||
for skin in ($c.skins | columns) {
|
||
let sk = ($c.skins | get $skin)
|
||
if ($sk.alibis | length) != $n or ($sk.suspect_tags | length) != $n {
|
||
error make { msg: $"($c.id)/($skin): ($n) suspects but ($sk.alibis | length) alibis and ($sk.suspect_tags | length) tags — they are index-parallel" }
|
||
}
|
||
}
|
||
{
|
||
id: $c.id
|
||
# THE SLUG IS THE PUBLIC IDENTITY; THE ID IS THE JOIN KEY.
|
||
#
|
||
# The viewer deep-linked by `id` — and every id in this series is SPANISH. So an English reader
|
||
# on the English viewer got `#validador-que-nunca-se-ejecuto` under a tab that says "The
|
||
# validator that never ran". The files even claim the id is "language-neutral"; it is not, it is
|
||
# Spanish, which is the same disease as everything else today: a declaration that says one thing
|
||
# while the data says another.
|
||
#
|
||
# The id stays the key (it is what makes a case ONE case across languages — the bilingual gate
|
||
# compares ids). The HASH becomes the slug, which is already localised and already the public
|
||
# URL of the article. Old `#<id>` links keep working: the resolver accepts either.
|
||
slug: $c.slug
|
||
caseNo: $c.case_no
|
||
classification: $c.classification
|
||
tab: $c.tab
|
||
printHref: $c.print_href
|
||
costCaption: ($c.cost_caption | md)
|
||
# The glossary is set in richer type here than in the article; same definition.
|
||
glossary: ($c.glossary | each {|t| { t: $t.term, d: (if ($t.def_html | is-empty) { $t.def | md } else { $t.def_html }) } })
|
||
glossaryMore: $"($v.md.protocol)<a href='($c.ontoref_url)' target='_blank' rel='noopener'>ontoref.dev</a>"
|
||
cost: (ledger $c.cost)
|
||
yielded: (ledger $c.yielded)
|
||
yieldedTitle: (if ($c.yielded_title | is-empty) { $v.md.yielded_title } else { $c.yielded_title })
|
||
suspectsTech: ($c.suspects | each {|s| $s.name })
|
||
# A snippet with no highlighted variant is shown escaped rather than not shown.
|
||
weaponCode: (if ($c.weapon.code_html | is-empty) { $c.weapon.code | str trim | esc } else { $c.weapon.code_html | str trim })
|
||
fixCode: (if ($c.fix.code_html | is-empty) { $c.fix.code | str trim | esc } else { $c.fix.code_html | str trim })
|
||
contain: ($c.containment | each {|r| [($r.facet | md), (if ($r.mechanism_html | is-empty) { $r.mechanism | md } else { $r.mechanism_html })] })
|
||
verdictClose: ($c.verdict_close | md)
|
||
# The replay's prompts are QUOTED TRANSCRIPTS: escaped for a <pre>, never md-converted —
|
||
# a backtick inside a prompt is part of the prompt. The steps/gate/adr ARE prose.
|
||
replay: (
|
||
if ($c.replay? | default null) == null { null } else {
|
||
{
|
||
before: ($c.replay.prompt_before | str trim | esc)
|
||
beforeSrc: ($c.replay.prompt_before_source | esc)
|
||
after: ($c.replay.prompt_after | str trim | esc)
|
||
steps: ($c.replay.steps | each {|s| [($s.task | md), ($s.verifiable | md)] })
|
||
gate: ($c.replay.gate_before_delegation | md)
|
||
adr: (if ($c.replay.adr_trigger | is-empty) { "" } else { $c.replay.adr_trigger | md })
|
||
}
|
||
}
|
||
)
|
||
lesson: {
|
||
edges: ($c.lesson_codified_in | each {|e| [($e.what | md), ($e.where | esc)] })
|
||
debt: (if ($c.lesson_debt | is-empty) { "" } else { $c.lesson_debt | md })
|
||
}
|
||
skin: ($c.skin | into string | str replace "'" "")
|
||
skins: ($c.skins | columns | reduce --fold {} {|skin, acc|
|
||
$acc | insert $skin (skin-data $c ($c.skins | get $skin) $lang $skin)
|
||
})
|
||
}
|
||
}
|
||
|
||
# The reading order is the one the articles already declare in their frontmatter. A second
|
||
# ordering rule would be a second source of truth.
|
||
def sort-order [ncl: path]: nothing -> int {
|
||
let md = ($ncl | str replace ".ncl" ".md")
|
||
if not ($md | path exists) { return 999 }
|
||
open --raw $md
|
||
| lines
|
||
| where {|l| $l | str starts-with "sort_order:" }
|
||
| get -o 0
|
||
| default "sort_order: 999"
|
||
| str replace --regex '^sort_order:\s*' ''
|
||
| str trim
|
||
| into int
|
||
}
|
||
|
||
def main [
|
||
--root: string = ".",
|
||
--check,
|
||
] {
|
||
let base = $"($root)/site/content/expedientes"
|
||
let tpl = (open --raw $"($root)/scripts/build/informe-template.html")
|
||
|
||
# The per-language surfaces (content dir, served grid, viewer URL) are DATA — the same
|
||
# declaration gen-expediente.nu reads, so the two projections of one source cannot disagree
|
||
# about where that source is published.
|
||
let surf_file = ([$root, "site", "config", "expedientes-surfaces.ncl"] | path join)
|
||
if not ($surf_file | path exists) {
|
||
error make { msg: $"no surface declaration at ($surf_file) — I cannot know where to publish" }
|
||
}
|
||
let surfaces = (^nickel export $surf_file | from json | get surfaces)
|
||
|
||
let langs = (ls $base | where type == dir | get name | each {|d| $d | path basename } | where {|d| not ($d | str starts-with "_") } | sort)
|
||
if ($langs | is-empty) { error make { msg: $"no locale dirs under ($base)" } }
|
||
|
||
mut drift = []
|
||
for lang in $langs {
|
||
let ncls = (glob $"($base)/($lang)/*/*.ncl")
|
||
if ($ncls | is-empty) { continue }
|
||
|
||
let cases = (
|
||
$ncls
|
||
| each {|f| { order: (sort-order $f), c: (^nickel export $f | from json) } }
|
||
| sort-by order
|
||
| each {|r| case-data $r.c $lang }
|
||
)
|
||
let v = ($VOCAB | get $lang)
|
||
let data = $" var UI = ($v.ui | to json -r);\n\n var L = ($v.md | to json -r);\n\n var CASES = ($cases | to json -r);"
|
||
|
||
let html = (
|
||
$tpl
|
||
| str replace --all "{{LANG}}" $lang
|
||
| str replace --all "{{TITLE}}" $v.ui.page_title
|
||
# The session-protocol page — the series' second standalone surface, declared in
|
||
# the same file as this viewer's own path. The viewer references it in its chrome.
|
||
| str replace "{{PROTO_HREF}}" (($surfaces | get $lang).protocol? | default "#")
|
||
| str replace "{{PROTO_LABEL}}" $v.md.cta_protocol
|
||
| str replace "{{DATA}}" $data
|
||
)
|
||
|
||
# ── THE GATE: NO MARKDOWN MAY REACH A READER AS TEXT ──────────────────────────────────────
|
||
#
|
||
# The converter above is a mechanism, and this project's whole thesis is that an unchecked
|
||
# mechanism is an intention. So the output is READ BACK before it is written, and any surviving
|
||
# markdown syntax ABORTS — naming the case and the fragment, because "there is markdown
|
||
# somewhere" is not a finding anyone can act on.
|
||
#
|
||
# It is deliberately the OUTPUT that is checked, not the input. A rule over the .ncl would only
|
||
# ever assert what the author typed; this asserts what the READER GETS, which is the only claim
|
||
# that was ever wrong. Every leak below was published for months precisely because five gates
|
||
# were reading files and none was reading the page.
|
||
#
|
||
# Note what is NOT checked: the `*_html` fields and the code blocks, which are authored HTML and
|
||
# may legitimately contain any character. The scan runs over the composed prose only.
|
||
let leaks = (
|
||
$cases | each {|c|
|
||
let prose = ([
|
||
$c.costCaption, $c.verdictClose,
|
||
# The lesson and the replay's PROSE are read back like everything else. The two
|
||
# prompts are excluded on purpose: they are escaped transcripts inside a <pre>,
|
||
# and a backtick there is part of what was typed, not markup that failed.
|
||
($c.lesson.edges | flatten),
|
||
$c.lesson.debt,
|
||
(if $c.replay == null { [] } else {
|
||
($c.replay.steps | flatten) | append $c.replay.gate | append $c.replay.adr
|
||
}),
|
||
($c.glossary | each {|g| $g.d }),
|
||
($c.cost | flatten | each {|x| $x | into string }),
|
||
($c.yielded | flatten | each {|x| $x | into string }),
|
||
($c.skins | columns | each {|s|
|
||
let sk = ($c.skins | get $s)
|
||
[$sk.logline, $sk.lead2, $sk.lead3, $sk.weaponCap, $sk.weaponAfter,
|
||
$sk.fixCap, $sk.fixAfter, $sk.verdictLead] | append $sk.alibis | append $sk.h2
|
||
}),
|
||
] | flatten | flatten | str join "\n")
|
||
let found = (
|
||
[
|
||
($prose | parse --regex '(?<m>\*\*[^*]+\*\*)' | get m)
|
||
($prose | parse --regex '(?<m>\[[^\]]+\]\(http[^)]*\))' | get m)
|
||
($prose | parse --regex '(?<m>`[^`]+`)' | get m)
|
||
] | flatten
|
||
)
|
||
if ($found | is-empty) { null } else { { case: $c.caseNo, leaks: $found } }
|
||
} | compact
|
||
)
|
||
if ($leaks | is-not-empty) {
|
||
let detail = ($leaks | each {|l| $" ($l.case): ($l.leaks | first 3 | str join ' · ')" } | str join "\n")
|
||
error make { msg: $"informe [($lang)]: markdown reaches the reader as literal text — the viewer injects with innerHTML, it does not parse Markdown:\n($detail)" }
|
||
}
|
||
|
||
# THE VIEWER'S URL IS DECLARED, NOT SPELLED HERE.
|
||
#
|
||
# It was `/images/expedientes/<lang>/expedientes.html` — an HTML document under an IMAGE path,
|
||
# with a Spanish word inside the English URL. site/config/expedientes-surfaces.ncl owns it now,
|
||
# per language, so the two generators cannot drift apart on it (adr-072: the surface is an axis
|
||
# of the extent, and an extent that lives in a script is folklore).
|
||
let viewer = ($surfaces | get $lang | get viewer)
|
||
let out = ([$root, "site", "public", ($viewer | str trim --left --char "/")] | path join)
|
||
|
||
# The old URL stays alive as a REDIRECT, not as a second copy. Links already shared — and any
|
||
# search engine that indexed it — land on the new one, and `canonical` tells the crawler which
|
||
# of the two is the page. Renaming a public URL and letting the old one 404 is not a rename;
|
||
# it is a deletion with extra steps.
|
||
let legacy = ([$root, "site", "public", "images", "expedientes", $lang, "expedientes.html"] | path join)
|
||
let stub = $"<!doctype html><html lang=\"($lang)\"><head><meta charset=\"utf-8\">
|
||
<title>($v.ui.page_title)</title>
|
||
<link rel=\"canonical\" href=\"($viewer)\">
|
||
<meta name=\"robots\" content=\"noindex,follow\">
|
||
<meta http-equiv=\"refresh\" content=\"0; url=($viewer)\"></head>
|
||
<body><p>This document has moved to <a href=\"($viewer)\">($viewer)</a>.</p></body></html>
|
||
"
|
||
if $check {
|
||
let cur = (if ($out | path exists) { open --raw $out } else { "" })
|
||
if $cur != $html { $drift = ($drift | append $"($lang)/expedientes.html") }
|
||
} else {
|
||
mkdir ($out | path dirname)
|
||
$html | save --force $out
|
||
mkdir ($legacy | path dirname)
|
||
$stub | save --force $legacy
|
||
print $"informe → ($lang)/expedientes.html [($cases | length) casos]"
|
||
}
|
||
}
|
||
|
||
if $check {
|
||
if ($drift | is-empty) {
|
||
print $"informe-check: ($langs | length) locale\(s\) reproduce from their .ncl — no hand-edited viewer"
|
||
} else {
|
||
print $"informe-check: DRIFT — ($drift | str join ', ') differ from what the .ncl produce."
|
||
print " the viewer is a projection; edit the .ncl, never the HTML."
|
||
exit 1
|
||
}
|
||
}
|
||
}
|