diff --git a/.github/scripts/check-test-filters.sh b/.github/scripts/check-test-filters.sh new file mode 100755 index 0000000..ff22587 --- /dev/null +++ b/.github/scripts/check-test-filters.sh @@ -0,0 +1,60 @@ +#!/usr/bin/env bash +# Guard contra «verificaciones fantasma» de la clase filtro-de-test (FU-020, CHARTER-12). +# +# El fallo que cierra: un comando `dotnet test --filter X` documentado o en CI que no casa con +# ningún test pasa EN VERDE ejecutando cero tests — su síntoma es idéntico al del éxito, así que +# sobrevive indefinidamente. Ocurrió con `Category=Concurrency` (quickstart US2, 0 tests durante +# meses hasta CHARTER-11) y sigue latente en `FullyQualifiedName~RedisDocumentStoreContractTests` +# (ci.yml): si alguien renombra esa clase, el job Redis pasa verde sin correr nada. +# +# Qué hace: descubre cada `dotnet test … --filter ` en los archivos vigilados, y para cada +# uno exige que `--list-tests --filter ` case con ≥1 test. `.NET` emite el texto exacto +# «No test matches the given testcase filter» cuando son cero — ese es el detector. +# +# Qué NO cubre (acotado a propósito, R3 del Charter): solo filtros de test. Un `dotnet run +# --project X` inexistente falla en ROJO, que es molesto pero no engañoso — otra clase. +set -euo pipefail + +# Archivos donde un filtro documentado/ejecutado puede quedar huérfano. +FILES=( + ".github/workflows/ci.yml" + ".github/workflows/release.yml" + "specs/001-weft-crdt-versioning/quickstart.md" + "CONTRIBUTING.md" + "README.md" +) + +fail=0 +found_any=0 + +# Extrae, de cada línea con `dotnet test … --filter …`: el proyecto (primer token `tests/…`) y el +# filtro (token tras --filter, con o sin comillas). +while IFS= read -r line; do + [[ "$line" == *"dotnet test"* && "$line" == *"--filter"* ]] || continue + + proj="$(sed -n 's/.*dotnet test[[:space:]]\+\(tests\/[A-Za-z0-9._\/-]*\).*/\1/p' <<<"$line")" + filter="$(sed -n 's/.*--filter[[:space:]]\+"\([^"]*\)".*/\1/p' <<<"$line")" + [[ -z "$filter" ]] && filter="$(sed -n 's/.*--filter[[:space:]]\+\([^[:space:]]*\).*/\1/p' <<<"$line")" + + [[ -z "$proj" || -z "$filter" ]] && continue + found_any=1 + + echo "· ${proj%/} --filter ${filter}" + out="$(dotnet test "$proj" --configuration Release --list-tests --filter "$filter" 2>&1 || true)" + if grep -qF "No test matches the given testcase filter" <<<"$out"; then + echo " ✗ FANTASMA: el filtro no casa con ningún test → el comando pasaría en verde con 0 tests" + fail=1 + else + echo " ✓ casa con ≥1 test" + fi +done < <(grep -rhnE "dotnet test.*--filter" "${FILES[@]}" 2>/dev/null || true) + +# El guard mismo no debe ser una verificación fantasma: si dejó de encontrar filtros, algo cambió +# (¿se renombró un archivo? ¿cambió la sintaxis?) y hay que revisarlo, no pasar en silencio. +if [[ "$found_any" -eq 0 ]]; then + echo "::error::el guard no encontró NINGÚN 'dotnet test --filter' en los archivos vigilados;" \ + "si de verdad ya no hay filtros documentados, actualiza este script — no lo dejes ciego" + exit 1 +fi + +exit $fail diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 65f7ffa..f371f11 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -38,7 +38,8 @@ jobs: with: dotnet-version: "10.0.x" # El shim se compila CON test-hooks para la suite de panic-safety (SC-009); el binario de - # release empaquetado (US4) se compila SIN la feature — pack-smoke verifica la ausencia. + # release empaquetado (US4) se compila SIN la feature — el job `native` de release.yml + # verifica con `nm` que los símbolos de test no están exportados en los cdylibs. - name: Build shim (test-hooks) working-directory: native run: cargo build --release --features test-hooks @@ -62,7 +63,7 @@ jobs: - uses: Swatinem/rust-cache@v2 with: workspaces: native - # Matriz de sanitizers sobre AMBOS shims (T035): sin -p corre todo el workspace nativo. + # AddressSanitizer + LeakSanitizer sobre AMBOS shims (T035): sin -p corre todo el workspace. - name: cargo test bajo AddressSanitizer + LeakSanitizer (yrs + loro) working-directory: native env: @@ -73,15 +74,16 @@ jobs: --target x86_64-unknown-linux-gnu # ── Fuzzing de la frontera FFI (research R14): smoke de 60 s por target en cada PR ───────── - # INFORMATIVO en M0 (continue-on-error): el fuzz destapó un DoS por amplificación de memoria en el - # decoder de yrs (upstream) — un update malformado de pocos bytes declara una longitud gigante y - # yrs hace `with_capacity(N)` sin cota; en un entorno con memoria limitada la asignación falla y - # Rust aborta (`handle_alloc_error`, NO capturable por catch_unwind). El shim FFI es correcto - # (contiene panics, sin UB). La mitigación real (validar el update / límite de recursos) llega en - # M2, donde entra input de red no confiable — ver AILOG-2026-07-10-001 §R6 y el follow-up. El job - # sigue corriendo y reportando en los logs, sin bloquear el cierre de M0. + # El job BLOQUEA A MEDIAS, y es deliberado: un fallo de COMPILACIÓN de los targets (o de instalar + # cargo-fuzz) pone el job rojo; un CRASH ENCONTRADO solo emite `::warning` (los `|| echo` de los + # steps `fuzz run`). NO hay `continue-on-error` en el job. La razón del trato informativo del + # crash es R6: el decoder de yrs amplifica memoria sobre un update malformado de pocos bytes + # (`with_capacity(N)` sin cota → `handle_alloc_error`, que catch_unwind no puede contener). El shim + # es correcto (contiene panics, sin UB); el fallo está aguas abajo, en yrs. La mitigación llegó en + # CHARTER-08 (PR upstream y-crdt#639 + caveat en GOVERNANCE.md §Seguridad); lo que resta es + # adoptar el fix vía bump (FU-015). Ver AILOG-2026-07-10-001 §R6. fuzz: - name: fuzz (smoke, informativo) + name: fuzz (smoke; crash informativo) runs-on: ubuntu-latest timeout-minutes: 20 steps: @@ -105,33 +107,35 @@ jobs: # El objetivo del smoke es que ningún input adversarial cause panic-through / UB / crash en # NUESTRO shim (research R14). Dos características del decoder de yrs sobre input malformado # obligan a configurar el harness para que mida eso y no artefactos de yrs (ver AILOG §R6): - # 1. `-s none` (sin ASan): yrs hace `with_capacity(N)` según una longitud declarada sin cota; - # glibc reserva esa memoria VIRTUAL (nunca se llena → RSS real ~150 MB) y yrs falla el - # decode → WEFT_ERR_DECODE. El allocator de ASan, en cambio, aborta reservas gigantes. - # La memory-safety la cubre el job `asan` sobre la suite determinista. + # 1. `-s none` (sin ASan): en las rutas de yrs que aún usan `with_capacity(N)` según una + # longitud declarada sin cota (el residual es id_set/state_vector vía export_since; + # Update::decode ya usa try_reserve en 0.27.2), glibc reserva esa memoria VIRTUAL (nunca se + # llena → RSS real ~150 MB) y yrs falla el decode → WEFT_ERR_DECODE. El allocator de ASan, + # en cambio, aborta reservas gigantes. La memory-safety la cubre el job `asan`. # 2. Hook de panic silenciado en el harness (fuzz_targets/*.rs): yrs usa `assert!` en algunos # updates malformados; el shim los contiene con `catch_unwind` → WEFT_ERR_PANIC (verificado # en tests). libfuzzer-sys aborta en su panic hook antes de que catch_unwind actúe, así que # el harness lo silencia para ejercitar el mismo camino que producción. Un SIGSEGV/UB real # no pasa por el hook y sigue detectándose. - # La resistencia a amplificación con input no confiable se endurece en la capa de servidor - # (M2, límites de tamaño de mensaje + recursos del proceso). - # Informativo (R6): un crash del fuzz sobre la amplificación de memoria del decoder de yrs no - # bloquea M0. El comando captura ese crash y emite una ANOTACIÓN de warning visible en el PR; - # el step sale con éxito (check verde, no rojo). Un fallo de infraestructura (compilación de - # los targets, instalación) rompe el job en los steps anteriores. La mitigación real va en M2. + # La resistencia a amplificación con input de red no confiable ya está en la capa de servidor + # (FU-002 cerrado: cap de tamaño de mensaje + límites de recursos por conexión, ver + # WeftServerOptions). Para la RUTA DIRECTA del FFI, el caveat está en GOVERNANCE.md §Seguridad. + # Crash informativo (R6): un crash del fuzz sobre la amplificación del decoder de yrs no rompe + # el job. El `|| echo` captura ese crash y emite una ANOTACIÓN de warning visible en el PR; el + # step sale con éxito (check verde). Un fallo de infraestructura (compilar los targets, + # instalar cargo-fuzz) rompe el job en los steps anteriores, que NO llevan `|| echo`. - name: Fuzz weft_doc_load (60 s) working-directory: native/weft-yrs-ffi run: >- cargo +nightly fuzz run -s none doc_load -- -max_total_time=60 -rss_limit_mb=0 -max_len=8192 - || echo "::warning title=fuzz informativo (R6)::doc_load halló un crash por amplificación de memoria del decoder de yrs — no bloquea M0; mitigación en M2 (ver AILOG R6)" + || echo "::warning title=fuzz informativo (R6)::doc_load halló un crash por amplificación de memoria del decoder de yrs — no rompe el job; fix upstream y-crdt#639, adopción vía FU-015" - name: Fuzz weft_doc_apply_update (60 s) working-directory: native/weft-yrs-ffi run: >- cargo +nightly fuzz run -s none apply_update -- -max_total_time=60 -rss_limit_mb=0 -max_len=8192 - || echo "::warning title=fuzz informativo (R6)::apply_update halló un crash por amplificación de memoria del decoder de yrs — no bloquea M0; mitigación en M2 (ver AILOG R6)" + || echo "::warning title=fuzz informativo::apply_update halló un crash — no rompe el job. Nota: Update::decode ya usa try_reserve en yrs 0.27.2, así que un crash aquí probablemente NO sea amplificación R6; revisar el artefacto" # Regresión R6 (CHARTER-08): ruta RESIDUAL state_vector::decode vía weft_doc_export_since — el # único sitio de yrs que aún usa with_capacity sin acotar (id_set/state_vector; Update::decode ya # usa try_reserve). Informativo hasta adoptar el fix upstream (FU-015); prueba el fix al bumpear. @@ -147,15 +151,14 @@ jobs: run: >- cargo +nightly fuzz run -s none loro_doc_load -- -max_total_time=60 -rss_limit_mb=0 -max_len=8192 - || echo "::warning title=fuzz informativo (loro)::loro_doc_load halló un crash — no bloquea M0 (ver AILOG)" + || echo "::warning title=fuzz informativo (loro)::loro_doc_load halló un crash — no rompe el job (ver AILOG)" - name: Fuzz loro_apply_update (60 s) working-directory: native/weft-loro-ffi run: >- cargo +nightly fuzz run -s none loro_apply_update -- -max_total_time=60 -rss_limit_mb=0 -max_len=8192 - || echo "::warning title=fuzz informativo (loro)::loro_apply_update halló un crash — no bloquea M0 (ver AILOG)" + || echo "::warning title=fuzz informativo (loro)::loro_apply_update halló un crash — no rompe el job (ver AILOG)" - # ── Gates que se activan en fases posteriores (jobs nombrados, T004) ────────────────────── # ── Gate P-III: determinismo del encoding (bloqueante desde US1) ────────────────────────── determinism: name: determinism @@ -175,7 +178,10 @@ jobs: run: cargo build --release --features test-hooks - name: Determinism gate run: dotnet test tests/Weft.Determinism.Tests/ --configuration Release - # El cross-implementación vs Yjs JS (mismo hash en todos los RIDs) se añade en US4 (T058). + # La paridad cross-implementación yrs↔Yjs es BLOQUEANTE y vive AQUÍ, en Weft.Determinism.Tests + # (`Yrs_export_matches_yjs_golden` contra tests/determinism-yjs/golden.json) — se promovió en + # CHARTER-09/FU-012. El job Node `determinism-yjs` de release.yml es solo informativo (regenera + # el hash de Yjs para cazar drift del upstream), no la aserción de paridad. # ── Gate P-IV: suite de versionado sobre yrs Y Loro (bloqueante desde US5, SC-008) ──────── dual-engine: @@ -231,11 +237,28 @@ jobs: runs-on: ubuntu-latest timeout-minutes: 5 steps: - # El pack-smoke multi-RID real (SC-007/P-VI) + la verificación de ausencia de weft_test_panic - # (SC-009) viven en `release.yml` (CHARTER-07/T057): la matriz cross-compile es cara, así que - # corre por `workflow_dispatch` (dry-run), NO en cada PR. Este job por-PR queda como marcador. + # El pack-smoke multi-RID real (SC-007/P-VI) + la verificación de ausencia de los símbolos de + # test (SC-009) viven en `release.yml` (CHARTER-07/T057): la matriz cross-compile es cara, así + # que corre por `workflow_dispatch` (dry-run), NO en cada PR. Este job por-PR queda como + # marcador — y así se documenta con honestidad, en vez de fingir que valida el empaquetado. - run: echo "pack-smoke multi-RID vive en release.yml (workflow_dispatch, dry_run) — CHARTER-07 T057." + # ── Guard contra «verificaciones fantasma» de la clase filtro-de-test (FU-020, CHARTER-12) ──── + # BLOQUEANTE. Un `dotnet test --filter X` que no casa con ningún test pasa en verde ejecutando + # cero — su síntoma es idéntico al del éxito. Este guard exige que cada filtro documentado/en CI + # case con ≥1 test. Solo necesita .NET (usa --list-tests, no ejecuta la suite); ~1 min. + test-filters: + name: test-filters (guard fantasma) + runs-on: ubuntu-latest + timeout-minutes: 5 + steps: + - uses: actions/checkout@v5 + - uses: actions/setup-dotnet@v5 + with: + dotnet-version: "10.0.x" + - name: Cada --filter documentado casa con ≥1 test + run: bash .github/scripts/check-test-filters.sh + # ── Gate M1 (P-V, SC-006): prueba de carga de concurrencia ──────────────────────────────── # Nightly (schedule) + manual (workflow_dispatch): NO corre en PR (no lo bloquea), pero es # BLOQUEANTE para el cierre de M1 — el nightly debe estar verde. El harness sale con código ≠ 0 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 498e1b2..8b78f6d 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -88,25 +88,53 @@ jobs: # Quedarnos solo con el cdylib (descartar .d/.rlib/.pdb/.lib/.exp). find out -type f ! -name '*.so' ! -name '*.dll' ! -name '*.dylib' -delete ls -la out - - name: Verify weft_test_panic NOT exported (SC-009) + # SC-009: los test-hooks NUNCA viajan en release. Dos correcciones de CHARTER-12: + # (a) el patrón era `weft_test_panic`, que NO casa con `weft_loro_test_panic` (no es + # substring) → el shim de Loro quedaba fuera del gate. Ahora se buscan ambos. + # (b) el gate pasaba en verde si la herramienta faltaba: `nm ... 2>/dev/null | grep -q` no + # distingue «no está el símbolo» de «no está nm». Un gate que no sabe fallar es la + # verificación fantasma que este Charter cierra, y estaba dentro del propio gate. + # Ahora: la herramienta debe existir, y debe demostrar que ve un símbolo de control que + # SÍ está en el binario (weft_abi_version) antes de creerle una ausencia. + - name: Verify test hooks NOT exported (SC-009) working-directory: native shell: bash run: | set -euo pipefail + + symbols() { # $1 = archivo → lista de símbolos, o falla si no hay herramienta + case "$1" in + *.so|*.dylib) nm -D "$1" 2>/dev/null || nm "$1" ;; + *.dll) strings "$1" ;; + esac + } + + case "$RUNNER_OS" in + Windows) command -v strings >/dev/null || { echo "::error::falta 'strings': el gate SC-009 no puede verificar nada"; exit 1; } ;; + *) command -v nm >/dev/null || { echo "::error::falta 'nm': el gate SC-009 no puede verificar nada"; exit 1; } ;; + esac + fail=0 for f in out/*; do - case "$f" in - *.so|*.dylib) - if nm -D "$f" 2>/dev/null | grep -q weft_test_panic \ - || nm "$f" 2>/dev/null | grep -q weft_test_panic; then - echo "✗ $f exporta weft_test_panic (test-hooks filtrado en release)"; fail=1 - else echo "✓ $f sin weft_test_panic"; fi ;; - *.dll) - # En Windows/bash usamos strings como verificación débil (test-hooks no compilado en release). - if strings "$f" 2>/dev/null | grep -q weft_test_panic; then - echo "✗ $f contiene weft_test_panic"; fail=1 - else echo "✓ $f sin weft_test_panic"; fi ;; - esac + [ -f "$f" ] || continue + syms="$(symbols "$f")" + + # Control positivo: si no vemos un símbolo que SÍ está, la ausencia de otro no prueba + # nada. Se busca `abi_version` (substring de weft_abi_version Y weft_loro_abi_version): + # buscar `weft_abi_version` fallaría en el cdylib de Loro por el mismo motivo de + # substring que dejaba fuera a `weft_loro_test_panic`. + if ! printf '%s' "$syms" | grep -q abi_version; then + echo "::error::$f — la herramienta no encuentra ni *abi_version: el gate está ciego, no limpio" + fail=1; continue + fi + + found=0 + for hook in weft_test_panic weft_loro_test_panic; do + if printf '%s' "$syms" | grep -q "$hook"; then + echo "✗ $f expone $hook (test-hooks filtrado en release)"; found=1; fail=1 + fi + done + [ "$found" -eq 0 ] && echo "✓ $f sin test hooks (control positivo: weft_abi_version visible)" done exit $fail - name: Upload native artifacts diff --git a/.straymark/07-ai-audit/agent-logs/AILOG-2026-07-16-001-charter-12-cerrar-declaracion-sin-cableado.md b/.straymark/07-ai-audit/agent-logs/AILOG-2026-07-16-001-charter-12-cerrar-declaracion-sin-cableado.md new file mode 100644 index 0000000..e51c5fa --- /dev/null +++ b/.straymark/07-ai-audit/agent-logs/AILOG-2026-07-16-001-charter-12-cerrar-declaracion-sin-cableado.md @@ -0,0 +1,130 @@ +--- +id: AILOG-2026-07-16-001 +title: "CHARTER-12: cerrar la clase «declaración de superficie sin cableado» — paridad header↔binding, guard de filtros, comentarios que mienten" +status: accepted +created: 2026-07-16 +agent: claude-opus-4-8 +confidence: high +review_required: true +risk_level: low +eu_ai_act_risk: not_applicable +nist_genai_risks: [] +iso_42001_clause: [] +observability_scope: none +tags: [follow-ups, phantom-verification, header-binding-parity, ci-guard, sc-009, false-comments, ffi-boundary] +related: [AILOG-2026-07-15-003, AILOG-2026-07-15-002] +originating_charter: CHARTER-12-cerrar-declaracion-sin-cableado +--- + +# AILOG: CHARTER-12 — cerrar la clase «declaración de superficie sin cableado» + +## Summary + +Despacho de los cuatro follow-ups accionables del backlog (FU-017/018/019/020) más los hallazgos +adyacentes que la investigación destapó, todos de la misma clase: *verificación fantasma* — una +declaración que pasa en verde sin verificar nada, nombrada en CHARTER-11. Primero de tres Charters que +vacían el backlog antes del publish (T060). + +El hallazgo que ordenó el Charter: **el repo afirmaba en dos sitios que existía un test de CI que no +existía.** `NativeMethods.cs:8` y `weft_ffi.h:4-6` decían «un test de CI valida que las declaraciones +`[LibraryImport]` coinciden con este header». No había ninguno —ni para yrs ni para Loro—, y esa +afirmación imaginaria fue la que engañó a quien redactó FU-017 («replicar para Loro el test que yrs +tiene»). No había nada que replicar; había que crearlo por primera vez. + +Con esto el backlog baja de 7 open a 3 (FU-010, FU-015, FU-016). + +## Actions Performed + +1. **FU-017 — `tests/Weft.Versioning.Tests/HeaderBindingParityTests.cs`** (nuevo): paridad + header↔binding para **ambos** shims. Parser acotado del subconjunto de C que estos headers usan + + reflexión sobre `NativeMethods`; compara conjunto de funciones, aridad, orden y tipos (mapa C↔.NET). + **Probado contra mutaciones reales del header** (función extra → detectada; `size_t`→`uint32_t` → + detectada) y con dos casos negativos en el propio archivo (el parser detecta divergencias sintéticas + y **revienta ante lo que no entiende** en vez de igncrarlo — ignorar sería el fantasma que persigue). + El doc-comment declara explícitamente **qué NO cubre** (semántica, marshalling, ownership: eso sigue + siendo ASan + round-trips), porque exagerar el alcance de un test es la clase que este Charter cierra. +2. **FU-017 (b)** — corregidos los dos comentarios que afirmaban el test inexistente (`NativeMethods.cs:8`, + `weft_ffi.h:4-6`); ahora son ciertos, y `weft_ffi.h` aclara que el header se mantiene a mano (no hay + csbindgen; la mención en research R1 era aspiracional). +3. **FU-017 (c)** — completado el header de Loro: declara `weft_loro_test_panic` bajo + `#ifdef WEFT_TEST_HOOKS` (el shim lo exporta, `lib.rs:472`) y marca su ABI (v2) explícitamente. +4. **Hueco de gate SC-009 (NUEVO)** — `release.yml` usaba `grep -q weft_test_panic`, que **no caza** + `weft_loro_test_panic` (no es substring): el shim de Loro quedaba fuera del gate. Ampliado a ambos + símbolos. **Verificado localmente** contra un cdylib de Loro con `test-hooks`: el gate viejo lo dejaba + pasar; el nuevo lo caza. +5. **Control positivo del gate SC-009 (NUEVO)** — el gate pasaba en verde si `nm`/`strings` faltaban + (`... 2>/dev/null | grep -q` no distingue «no está el símbolo» de «no está la herramienta»). Era la + clase de FU-020 **dentro del propio gate**. Ahora: la herramienta debe existir, y debe demostrar que + ve `abi_version` (símbolo de control que SÍ está) antes de creer una ausencia. **Al montarlo apareció + un bug propio**: el control buscaba `weft_abi_version`, que por el mismo motivo de substring no casa + con `weft_loro_abi_version`; corregido a `abi_version`. Probado en 4 casos (limpio/contaminado × + yrs/loro). +6. **FU-020 — `.github/scripts/check-test-filters.sh` + job `test-filters`** (bloqueante): descubre cada + `dotnet test --filter X` en los archivos vigilados y exige que `--list-tests --filter X` case con ≥1 + test (señal: el texto «No test matches the given testcase filter»). Cubre el fantasma vivo de + `ci.yml` (`FullyQualifiedName~RedisDocumentStoreContractTests`). El guard **falla si deja de + encontrar filtros** (no se deja ciego a sí mismo). **Probado**: pasa con los 2 filtros reales; falla + con un fantasma inyectado. +7. **FU-018 — ~10 comentarios falsos** corregidos en `ci.yml` (cabecera del job `fuzz` con el falso + `continue-on-error` y el obsoleto «M2»; warnings de los steps; `with_capacity` genérico ya no cierto + para `apply_update`; «matriz de sanitizers» inexistente; nombre del job; «gates que se activan en + fases posteriores» ya activos; cross-impl «se añade en US4» ya hecho y bloqueante), `CONTRIBUTING.md` + (paridad Yjs marcada «no-bloqueante» → bloqueante; ruta del brief) y `README.md` (`NOTICE` + inexistente, `native/weft-ffi/` layout muerto, ruta del brief). +8. **Comando roto + evidencia falsa (NUEVO, error propio de CHARTER-11)** — `quickstart.md:37` + (`dotnet test tests/A tests/B` → MSB1008) partido en dos comandos; `checklists/requirements.md:45` + afirmaba «ese comando → 58/58», que era falso (se corrió `dotnet test Weft.sln` y se sumó a ojo; el + real es 28+36=64). Corregido con la nota de que fue R4 de CHARTER-11 materializándose. +9. **FU-019 — footgun de pack local** documentado en `CONTRIBUTING.md`: empaquetar desde un árbol + compilado con `--features test-hooks` metería el símbolo de test en el `.nupkg`; el gate solo corre + en `release.yml`. +10. **Pase adversarial** de las ~10 correcciones contra el código en HEAD (mitigación de R4): sin + afirmaciones nuevas falsas. Incluyó verificar contra la fuente de yrs 0.27.2 en el caché de cargo + que `Update::decode` usa `try_reserve` (afirmación sobre código de terceros). + +## Risk + +Riesgos del Charter (R1–R6) y su desenlace: + +- **R1 (el parser de C se vuelve el problema)** — mitigado y probado: el parser revienta ante lo que no + entiende (test dedicado) en vez de ignorarlo, y su capacidad de fallar se verificó con mutaciones + reales del header, no solo con verde. +- **R2 (falsa seguridad: paridad sintáctica, no semántica)** — mitigado: el doc-comment declara el + alcance exacto; la semántica sigue en ASan + round-trips. +- **R3 (el guard solo cubre `--filter`)** — aceptado y acotado por escrito: los comandos rotos fallan en + rojo (otra clase), como demostró `quickstart.md:37`. +- **R4 (corregir comentarios introduce falsedades nuevas)** — **no se materializó**, y esta vez la + mitigación se aplicó con disciplina: cada corrección anclada a `archivo:línea` en HEAD (incluido el + YAML, que fue donde falló en CHARTER-11) + pase adversarial. Contraste explícito con CHARTER-11, donde + sí se coló una falsedad heredada de un comentario. +- **R5 (control positivo del gate difícil sin binario con el símbolo)** — resuelto usando un símbolo de + control ya presente (`abi_version`), sin duplicar la matriz. +- **R6 (colisión con CHARTER-14 en el header de Loro)** — es la razón del orden 12→14; este Charter no + toca el valor del ABI, solo declara el símbolo de test y documenta v2. CHARTER-14 sube a v3 y el test + de paridad —ya existente— validará ese cambio. + +**R7 (nuevo, no en el Charter) — mi propio gate reescrito tenía el mismo bug que arreglaba.** El control +positivo del gate SC-009 buscaba `weft_abi_version`, que no casa con `weft_loro_abi_version` por el mismo +motivo de substring que dejaba fuera a `weft_loro_test_panic`. Lo cacé **porque probé el gate contra un +binario de Loro real**, no porque lo revisara. Es la lección recurrente en una variante nueva: un gate no +verificado ejecutándose es tan fantasma como el que corrige. Corregido a `abi_version` y reprobado. + +## Follow-ups + +Ninguno nuevo. Los cuatro accionables quedan cerrados; el pase adversarial no dejó residuo. + +Nota sobre lo NO tocado (decisión consciente, ver §Out of scope del Charter): el `pack-smoke` por-PR +sigue siendo un marcador `echo` —su refactor a validación real es coste de matriz cross-compile, decisión +de CHARTER-07— pero su comentario ahora lo dice con honestidad en vez de fingir que valida el empaquetado. + +## Verification + +```bash +cargo build --release --features test-hooks --manifest-path native/Cargo.toml +dotnet test Weft.sln -c Release # 138/138 (6 nuevos de paridad) +dotnet test tests/Weft.Versioning.Tests -c Release --filter "FullyQualifiedName~HeaderBindingParity" # 6/6 +bash .github/scripts/check-test-filters.sh # los 2 filtros casan; falla con un fantasma +# Gate SC-009 probado local: caza weft_test_panic Y weft_loro_test_panic; falla si la herramienta está ciega +python3 -c "import yaml; yaml.safe_load(open('.github/workflows/ci.yml')); yaml.safe_load(open('.github/workflows/release.yml'))" +straymark validate --include-charters +``` diff --git a/.straymark/charters/12-cerrar-declaracion-sin-cableado.md b/.straymark/charters/12-cerrar-declaracion-sin-cableado.md new file mode 100644 index 0000000..99d47a3 --- /dev/null +++ b/.straymark/charters/12-cerrar-declaracion-sin-cableado.md @@ -0,0 +1,197 @@ +--- +charter_id: CHARTER-12-cerrar-declaracion-sin-cableado +status: in-progress +effort_estimate: L +trigger: "El operador decide vaciar el backlog de follow-ups ANTES del publish real (T060). El publish es irreversible: lo que quede mal documentado o sin cablear se congela en un paquete público. Los 4 follow-ups accionables (FU-017/018/019/020) resultaron ser la misma clase — el anti-patrón que CHARTER-11 destapó — y la investigación previa encontró que el repo afirma que existen verificaciones que NO existen." +originating_spec: specs/001-weft-crdt-versioning/spec.md +work_verb: implement +design_provenance: new +--- + +# Charter: Cerrar la clase «declaración de superficie sin cableado» + +> **Status (mirrored from frontmatter — source of truth is above):** in-progress. Effort: L. +> +> **Origin:** Despacho del backlog de follow-ups antes de T060. Cierra FU-017, FU-018, FU-019 y FU-020, +> más los hallazgos adyacentes que la investigación destapó y que ningún follow-up cubría. + +## Context + +CHARTER-11 nombró el anti-patrón que gobierna este Charter: *«verificación fantasma»* — una declaración +que **pasa en verde sin verificar nada**, cuyo síntoma es idéntico al del éxito y que por eso sobrevive +indefinidamente. Su retrospectivo predijo que seguirían apareciendo mientras no existiera un guard +mecánico. La predicción se cumplió de inmediato: al investigar los 4 follow-ups accionables, los cuatro +resultaron ser la misma clase, y aparecieron tres instancias más que ningún follow-up había registrado. + +El hallazgo que ordena el Charter: **el repo afirma en dos sitios que existe un test de CI que no +existe.** `src/Weft.Core/Yrs/NativeMethods.cs:8` («un test de CI lo valida») y +`native/weft-yrs-ffi/include/weft_ffi.h:4-6` («Un test de CI valida que las declaraciones +`[LibraryImport]` de Weft.Core coinciden con este header») describen una verificación que nunca se +implementó. No es una verificación fantasma en el sentido de CHARTER-11 —no hay job que pase en verde—: +es peor, es una **verificación imaginaria**. Y ya hizo daño: engañó a quien redactó FU-017, que pide +«replicar para Loro el test que yrs SÍ tiene». No hay nada que replicar. + +Esto lo cambia todo respecto a lo declarado en el backlog: FU-017 no es un chore de coste S; es crear +el test **por primera vez, para ambos shims**, y borrar dos afirmaciones falsas. Y llega justo a tiempo: +CHARTER-14 (siguiente en la secuencia) va a tocar exactamente esa superficie y a subir el ABI del shim de +Loro a v3. + +## Scope + +**In scope:** + +1. **FU-017 — test de paridad header↔binding, para AMBOS shims** (`weft_ffi.h` ↔ `Weft.Core` y + `weft_loro_ffi.h` ↔ `Weft.Loro`): compara nombre, aridad, orden y tipos de parámetros, y retorno. + Falla si el header declara algo que el binding no tiene, o al revés. Hoy las 15 firmas de Loro + coinciden: el test no arregla una divergencia, **impide la próxima**. +2. **FU-017 (b)** — corregir los dos comentarios que afirman que ese test existe; pasarán a ser ciertos. +3. **FU-017 (c)** — completar el header de Loro: declarar `weft_loro_test_panic` (que `lib.rs:472` + exporta bajo la feature) y marcar su ABI explícitamente, como hace el de yrs. +4. **Hueco de gate SC-009 (NUEVO)** — `release.yml:100` usa `grep -q weft_test_panic`, que **no caza** + `weft_loro_test_panic`: el gate que impide que los test-hooks viajen en release **no cubre el cdylib + de Loro**. Ampliarlo a ambos símbolos. +5. **Control positivo del gate SC-009 (NUEVO)** — hoy, si `nm`/`strings` faltasen en el runner, el script + imprime `✓ sin weft_test_panic` y pasa en verde sin verificar nada (`release.yml:105` lo llama + «verificación débil»). Es la clase de FU-020 **dentro del gate que debería protegernos**. Asertar que + la herramienta existe y que detecta el símbolo en un control positivo. +6. **FU-018 — ~10 comentarios falsos u obsoletos** en `ci.yml`, `CONTRIBUTING.md:41` y `README.md` + (inventario en `## Files to modify`). Patrón común: el comentario viejo sobrevive junto al nuevo que + lo desmiente — varios se contradicen dentro del mismo archivo. +7. **FU-020 — guard BLOQUEANTE de filtros de test**: para cada `--filter X` documentado o usado en CI, + `dotnet test --list-tests --filter X` debe devolver ≥1 resultado. Cubre el fantasma vivo de + `ci.yml:227` (`FullyQualifiedName~RedisDocumentStoreContractTests`: si alguien renombra la clase, el + job pasa verde con 0 tests). +8. **Comando roto + evidencia falsa (NUEVO)** — `quickstart.md:37` (`dotnet test tests/A tests/B` → + MSB1008, verificado) y la evidencia de US1 en `checklists/requirements.md:45`, que afirma haber + ejecutado ese comando con resultado 58/58. Corregir ambos. +9. **FU-019 — footgun de pack local**: documentar en `CONTRIBUTING.md` que compilar con `--target + --features test-hooks` y empaquetar en local metería el símbolo de test en el `.nupkg` (el + gate solo existe en CI). + +**Out of scope:** + +- **FU-010** → CHARTER-13 (durabilidad del relay). **FU-016** → CHARTER-14 (siembra cross-engine). +- **FU-015** — bloqueado por terceros: y-crdt#639 sigue abierto. No accionable. +- **Refactor del `pack-smoke` por-PR para que valide de verdad** — el `echo` de `ci.yml:229` es una + decisión de coste consciente de CHARTER-07 (la matriz cross-compile es cara), ya documentada con + honestidad en CHARTER-11. Este Charter no la revierte; solo puede reutilizar ese job como contenedor + del guard, que es coste ≈ 0. +- **Guards para las otras sub-clases del anti-patrón** (env-vars, instrumentos, rutas HTML) — el patrón + las sugiere, pero Weft no tiene esa superficie. Solo se cablea la sub-clase que este repo sí sufre. + +## Files to modify + +| File | Change | +|---|---| +| `tests/Weft.Versioning.Tests/HeaderBindingParityTests.cs` | New — paridad header↔binding para ambos shims (el proyecto ya referencia `Weft.Core` y `Weft.Loro`; evita tocar `Weft.sln`) | +| `src/Weft.Core/Yrs/NativeMethods.cs` | Corregir el comentario `:8` que afirma que existe un test de CI (pasará a ser cierto) | +| `native/weft-yrs-ffi/include/weft_ffi.h` | Corregir `:4-6` (misma afirmación falsa + csbindgen aspiracional) | +| `native/weft-loro-ffi/include/weft_loro_ffi.h` | Declarar `weft_loro_test_panic` bajo guard de feature; marcar ABI explícitamente | +| `.github/workflows/release.yml` | Gate SC-009: cazar **ambos** símbolos + control positivo (que `nm`/`strings` existan y detecten) | +| `.github/scripts/check-test-filters.sh` | New — guard de FU-020: cada `--filter` documentado casa con ≥1 test | +| `.github/workflows/ci.yml` | ~8 comentarios falsos/obsoletos + job bloqueante `test-filters` que corre el guard | +| `CONTRIBUTING.md` | `:41` paridad Yjs marcada «no-bloqueante» (es bloqueante) + footgun de pack local (FU-019) | +| `README.md` | `NOTICE` inexistente, `native/weft-ffi/` (layout muerto), ruta de `weft-design-brief.md` | +| `specs/001-weft-crdt-versioning/quickstart.md` | `:37` comando roto (MSB1008) | +| `specs/001-weft-crdt-versioning/checklists/requirements.md` | `:45` evidencia falsa de US1 | +| `.straymark/follow-ups-backlog.md` | Cierre de FU-017/018/019/020 (vía AILOG + `drift --apply`) | +| `.straymark/07-ai-audit/agent-logs/AILOG-2026-07-16-001-charter-12-cerrar-declaracion-sin-cableado.md` | New, `risk_level: low` | + +## Verification + +### Local checks + +```bash +cargo build --release --features test-hooks --manifest-path native/Cargo.toml +dotnet build Weft.sln -c Release + +# El test nuevo: paridad header↔binding en ambos shims +dotnet test tests/Weft.Versioning.Tests -c Release --filter "FullyQualifiedName~HeaderBindingParity" + +# Suite completa (hoy 132/132; este Charter la sube) +dotnet test Weft.sln -c Release +cargo test --features test-hooks --manifest-path native/Cargo.toml + +# El guard de FU-020 debe ejecutar >0 tests para cada filtro documentado +dotnet test tests/Weft.Core.Tests -c Release --filter Category=Concurrency +dotnet test tests/Weft.Server.Tests -c Release --filter "FullyQualifiedName~RedisDocumentStoreContractTests" + +# Gate de memoria (el header de Loro cambia; el shim no debe regresar) +RUSTFLAGS="-Zsanitizer=address" cargo +nightly test --features test-hooks \ + --target x86_64-unknown-linux-gnu --manifest-path native/Cargo.toml + +straymark validate --include-charters +``` + +### Production smoke (after deploy) + +No aplica: Weft es una librería. El gate SC-009 corregido se ejercita en el **dry-run** de `release.yml` +(`workflow_dispatch`), no en un despliegue: + +```bash +gh workflow run release.yml -f dry_run=true # valida el gate ampliado sobre los 4 RIDs +``` + +## Risks + +- **R1 — El test de paridad es un parser de C frágil y se vuelve el problema que venía a resolver**: + probabilidad media, severidad media. Parsear C de verdad es AST y no lo hay; un regex ingenuo dará + falsos positivos con macros, `#ifdef`, comentarios o continuaciones de línea. + Mitigación: el parser se acota deliberadamente al subconjunto que estos dos headers usan (declaraciones + `TYPE name(args);` de una superficie de 15 funciones escrita por nosotros), **falla ruidosamente si + encuentra algo que no entiende** en vez de ignorarlo en silencio (ignorar = fantasma nuevo), y se + verifica con un caso negativo: un header sintético con una firma divergente debe hacer fallar el test. + Si el parser no puede ser honesto, mejor un test que compare **nombres exportados** (superficie) que + uno que finja entender tipos. +- **R2 — Falsa sensación de seguridad: el test pasa pero la paridad real es semántica, no sintáctica**: + probabilidad media, severidad media. Que `size_t` ↔ `nuint` coincidan de nombre no prueba que el + marshalling sea correcto. + Mitigación: el doc-comment del test declara explícitamente **qué NO cubre** (semántica, convención de + llamada, ownership). La garantía real de esas propiedades sigue siendo ASan + los tests de + round-trip. Un test cuyo alcance se exagera es exactamente la clase que este Charter cierra. +- **R3 — El guard de filtros no caza la clase entera, solo `--filter`**: probabilidad alta, severidad + baja. Un `dotnet run --project X` que no existe, o un `npm run Y` inexistente, seguirían sin guard. + Mitigación: acotar la promesa por escrito — el guard cubre **filtros de test**, que es donde el + fantasma pasa en verde. Los comandos rotos fallan en **rojo** (como `quickstart.md:37`), que es + molesto pero no engañoso, y por tanto de otra clase. No se anuncia como más de lo que es. +- **R4 — Corregir ~10 comentarios introduce afirmaciones nuevas igual de falsas**: probabilidad media, + severidad alta. Es literalmente lo que pasó en CHARTER-11 (heredé `continue-on-error` de un comentario + en vez de leer el YAML). + Mitigación: cada comentario corregido se ancla a `archivo:línea` **leído en HEAD** —incluido el YAML, + que fue justo donde falló la regla la vez anterior— y el conjunto pasa un pase adversarial contra el + código antes del commit, como el que cazó las 5 afirmaciones falsas del doc de arquitectura. +- **R5 — El control positivo del gate SC-009 es difícil de montar sin un binario con el símbolo**: + probabilidad media, severidad baja. Compilar un cdylib con `test-hooks` solo para probar el gate + duplicaría la matriz. + Mitigación: el control positivo no necesita el cdylib real — basta verificar que la herramienta existe + (`command -v nm`) y que detecta un símbolo conocido **presente** en el mismo binario (p. ej. + `weft_abi_version`). Si no detecta lo que sí está, el gate está roto y debe fallar. +- **R6 — Tocar el header de Loro colisiona con CHARTER-14**, que sube su ABI a v3: probabilidad alta, + severidad baja. + Mitigación: es la razón del orden 12 → 14. Este Charter **no** toca el valor del ABI; solo declara el + símbolo de test y documenta la versión vigente (v2). CHARTER-14 la sube y el test de paridad, ya + existente, valida ese cambio — que es exactamente para lo que se construye ahora. + +## Tasks + +1. Sync main, branch `charter/12-cerrar-declaracion-sin-cableado`. +2. FU-017: test de paridad (con su caso negativo, R1) + corregir los 2 comentarios + completar el header + de Loro. +3. Gate SC-009: ambos símbolos + control positivo. +4. FU-020: guard bloqueante de filtros. +5. FU-018: los ~10 comentarios, anclados a HEAD (R4). +6. Comando roto de `quickstart.md:37` + evidencia falsa de US1 en `requirements.md:45`. +7. FU-019: footgun de pack local en `CONTRIBUTING.md`. +8. Pase adversarial de las correcciones contra el código (R4). +9. AILOG (`risk_level: low`) + `followups drift --apply`. +10. Verificación local limpia + `straymark charter drift CHARTER-12 --range origin/main..HEAD`. +11. Commit + push + PR. + +## Charter Closure + +1. **Atomic update (format v4)**: si el drift check reporta deriva no capturada en el AILOG, editar + `## Files to modify` y/o añadir `## Closing notes` **en este mismo PR**. +2. **Post-merge drift check**: `straymark charter drift CHARTER-12 --range origin/main..HEAD`. +3. **Status frontmatter** `in-progress` → `closed` + `closed_at`. +4. Al cerrar, `straymark followups status` debe bajar de 7 open a 3 (FU-010, FU-015, FU-016). +5. **No borrar** este archivo. diff --git a/.straymark/follow-ups-backlog.md b/.straymark/follow-ups-backlog.md index e011b2d..452473a 100644 --- a/.straymark/follow-ups-backlog.md +++ b/.straymark/follow-ups-backlog.md @@ -1,9 +1,9 @@ --- last_scan: 2026-07-15 schema_version: v1 -total_open: 7 +total_open: 3 total_promoted: 0 -total_closed_in_session: 13 +total_closed_in_session: 17 total_phase_blocked: 0 total_suspected_closed: 0 buckets: @@ -97,38 +97,38 @@ fully_extracted_ailogs: ### FU-017 — test de paridad header↔binding para el shim Loro - **Origin**: AILOG-2026-07-15-002 §Follow-ups · CHARTER-10 (se creó el header, sin test de paridad) - **Source-hash**: be38c88a4e9c -- **Status**: open +- **Status**: closed - **Trigger**: ready - **Destination**: chore - **Cost**: S -- **Notes**: CHARTER-10 creó `native/weft-loro-ffi/include/weft_loro_ffi.h`, pero ningún test automatizado verifica que las declaraciones `[LibraryImport]` de `Weft.Loro/Interop/NativeMethods.cs` coincidan con él. El shim yrs SÍ lo tiene (`weft_ffi.h` ↔ `Weft.Core`). Replicar ese test para Loro (paridad de firmas / regenerable con csbindgen). Mejora de robustez; ningún gate depende. +- **Notes**: CHARTER-10 creó `native/weft-loro-ffi/include/weft_loro_ffi.h`, pero ningún test automatizado verifica que las declaraciones `[LibraryImport]` de `Weft.Loro/Interop/NativeMethods.cs` coincidan con él. El shim yrs SÍ lo tiene (`weft_ffi.h` ↔ `Weft.Core`). Replicar ese test para Loro (paridad de firmas / regenerable con csbindgen). Mejora de robustez; ningún gate depende. **CERRADO 2026-07-16 (CHARTER-12, AILOG-2026-07-16-001)**: la premisa era FALSA — el test de yrs **no existía** (dos comentarios lo afirmaban sin implementación). `HeaderBindingParityTests` se creó por primera vez, para AMBOS shims, probado contra mutaciones reales del header; los dos comentarios que mentían quedaron corregidos. ### FU-018 — **Comentario obsoleto en `.github/workflows/ci.yml:76-81`** — **dos afirmaciones falsas**, y ya - **Origin**: AILOG-2026-07-15-003 §Follow-ups - **Source-hash**: 26789333fd7e -- **Status**: open -- **Trigger**: TBD -- **Destination**: TBD -- **Cost**: TBD -- **Notes**: Auto-appended by `straymark followups drift --apply` 2026-07-15. +- **Status**: closed +- **Trigger**: ready +- **Destination**: chore +- **Cost**: M +- **Notes**: Comentarios falsos/obsoletos en workflows y docs. **CERRADO 2026-07-16 (CHARTER-12, AILOG-2026-07-16-001)**: ~10 corregidos —cabecera del job `fuzz` (falso `continue-on-error` + obsoleto «M2»), warnings de steps, `with_capacity` genérico, «matriz de sanitizers», nombre del job, «gates posteriores», cross-impl «se añade en US4», `CONTRIBUTING.md:41` paridad Yjs «no-bloqueante», `README.md` (`NOTICE`/`native/weft-ffi/`/ruta del brief)— todos anclados a HEAD + pase adversarial (R4 no se materializó). ### FU-019 — **Footgun de pack local contaminado con `test-hooks`**: el pack lee de - **Origin**: AILOG-2026-07-15-003 §Follow-ups - **Source-hash**: d0e84c351361 -- **Status**: open -- **Trigger**: TBD -- **Destination**: TBD -- **Cost**: TBD -- **Notes**: Auto-appended by `straymark followups drift --apply` 2026-07-15. +- **Status**: closed +- **Trigger**: ready +- **Destination**: chore +- **Cost**: S +- **Notes**: El pack lee de `native/target//release/`; compilar con `--features test-hooks` y empaquetar en local metería el símbolo de test en el `.nupkg` (el gate SC-009 solo corre en `release.yml`). **CERRADO 2026-07-16 (CHARTER-12, AILOG-2026-07-16-001)**: documentado como aviso en `CONTRIBUTING.md` (§Construir y probar). Severidad baja; el gate de release es la red de seguridad real. ### FU-020 — **Guards de CI para las sub-clases del anti-patrón** (paso 5 del walkthrough del patrón de Polish). El - **Origin**: AILOG-2026-07-15-003 §Follow-ups - **Source-hash**: fc2f384b719f -- **Status**: open -- **Trigger**: TBD -- **Destination**: TBD -- **Cost**: TBD -- **Notes**: Auto-appended by `straymark followups drift --apply` 2026-07-15. +- **Status**: closed +- **Trigger**: ready +- **Destination**: chore +- **Cost**: M +- **Notes**: Guard mecánico contra «verificaciones fantasma». **CERRADO 2026-07-16 (CHARTER-12, AILOG-2026-07-16-001)**: `.github/scripts/check-test-filters.sh` + job bloqueante `test-filters`, para la sub-clase que este repo sí sufre (filtros de test que no casan → verde con 0 tests). Cubre el fantasma vivo `FullyQualifiedName~RedisDocumentStoreContractTests`. Probado: pasa con los 2 filtros reales, falla con uno inyectado. Las otras sub-clases del patrón (env-vars, instrumentos, rutas HTML) no aplican a Weft. ## Bucket: time-triggered ## Bucket: charter-triggered diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index f3faf63..fc313de 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -29,6 +29,13 @@ dotnet build Weft.sln -c Release dotnet test Weft.sln -c Release # el test de Redis se salta sin WEFT_TEST_REDIS (Valkey/Redis local) ``` +> **No empaquetes (`dotnet pack`) desde un árbol compilado con `--features test-hooks`.** El pack lee +> el cdylib de `native/target//release/`; si lo construiste con la feature (p. ej. tras +> `cargo build --release --target --features test-hooks`), el `.nupkg` incluiría el símbolo +> `weft_test_panic`/`weft_loro_test_panic`. El gate SC-009 que verifica su ausencia solo corre en el +> pipeline de `release.yml`, **no** en un pack local. Para publicar, compila el nativo **sin** la +> feature (FU-019). + ## Gates (constitución) La constitución del proyecto (`.specify/memory/constitution.md`) fija 6 principios **vinculantes**, cada uno @@ -38,7 +45,7 @@ con su gate de CI. Un PR no se mergea sin ellos en verde: |---|---| | **P-I** FFI segura | ningún panic cruza la frontera C (`catch_unwind` en cada entrada) | | **P-II** Memoria verificada | ASan/LSan sobre los tests Rust de ambos shims — 0 fugas / 0 double-free | -| **P-III** Determinismo | encoding reproducible cross-RID (+ cross-impl vs Yjs, no-bloqueante) | +| **P-III** Determinismo | encoding reproducible cross-RID + paridad byte-idéntica cross-impl vs Yjs (**bloqueante** desde CHARTER-09) | | **P-IV** Motor reemplazable | la suite de versionado corre idéntica sobre `yrs` **y** Loro (dual-engine) | | **P-V** Concurrencia por doc | acceso a `ICrdtDoc` serializado; el broker usa actor/canal single-reader | | **P-VI** Portabilidad por RID | *pack-smoke* del paquete en cada RID soportado — "soportado" = ejercitado | @@ -66,7 +73,7 @@ Las versiones de los motores están **pinneadas exactas** (`yrs = "=0.27.2"`, `l Spec-driven con [GitHub Spec Kit](https://github.com/github/spec-kit) (spec → plan → tasks → implement) y gobernanza documental con [StrayMark](https://github.com/StrangeDaysTech/straymark) (Charters + AILOG/AIDEC). -Ver [`GOVERNANCE.md`](./GOVERNANCE.md). Las decisiones ✅ CERRADO del brief (`docs/weft-design-brief.md`) no se +Ver [`GOVERNANCE.md`](./GOVERNANCE.md). Las decisiones ✅ CERRADO del brief (`weft-design-brief.md`) no se re-litigan. ## Reportar bugs / proponer cambios diff --git a/README.md b/README.md index 08c2dc6..49ba53b 100644 --- a/README.md +++ b/README.md @@ -85,27 +85,27 @@ Recorrido end-to-end (editar → publicar → servir → cliente Tiptap) en - **Cliente de editor recomendado:** [Tiptap](https://tiptap.dev) (sobre ProseMirror) + `y-prosemirror`, conectado al servidor relay de Weft. - **Dual-path:** [Loro](https://github.com/loro-dev/loro) queda como alternativa viva tras la abstracción; cambiar de motor = cambiar el adaptador, no la capa de versionado. -## Estructura del repo (propuesta) +## Estructura del repo ``` weft/ ├── LICENSE # Apache-2.0 ├── README.md -├── NOTICE -├── .gitignore -├── native/ -│ └── weft-ffi/ # crate Rust cdylib: shim C-ABI sobre yrs (+ contrato de ownership) -│ ├── Cargo.toml # yrs pinneado (=X.Y.Z) -│ ├── src/lib.rs -│ ├── include/weft_ffi.h # header C (contrato) -│ └── tests/mem_asan.rs # harness de memoria (ASan/LSan) +├── native/ # workspace cargo (un solo build cubre ambos shims) +│ ├── weft-yrs-ffi/ # cdylib: shim C-ABI sobre yrs (motor por defecto) +│ │ ├── src/lib.rs # Cargo.toml pinnea yrs = "=X.Y.Z" +│ │ ├── include/weft_ffi.h # header C (contrato de ownership; fuente de verdad) +│ │ └── tests/mem_asan.rs # harness de memoria (ASan/LSan) +│ └── weft-loro-ffi/ # cdylib: shim C-ABI sobre Loro (dual-path) ├── src/ -│ ├── Weft.Core/ # ICrdtEngine/ICrdtDoc, P/Invoke [LibraryImport], SafeHandle +│ ├── Weft.Core/ # ICrdtEngine/ICrdtDoc, P/Invoke [LibraryImport], SafeHandle, broker │ ├── Weft.Versioning/ # publish/diff/branch/merge/compact (content-addressed) │ ├── Weft.Server/ # relay WebSocket, awareness, persistencia +│ ├── Weft.Server.Persistence.EFCore/ # adaptador IDocumentStore sobre EF Core +│ ├── Weft.Server.Persistence.Redis/ # adaptador IDocumentStore sobre Redis/Valkey │ └── Weft.Loro/ # adaptador opcional (INativeVersioning) — dual-path ├── tests/ -├── docs/ # briefs, ICrdtEngine, decisiones +├── docs/ # architecture.md, api/, spikes/ ├── .specify/ # GitHub Spec Kit (spec/plan/tasks) └── .github/workflows/ # CI: build multi-RID, tests, ASan, fuzzing, determinismo ``` @@ -119,7 +119,7 @@ tocar el shim. La referencia por paquete está en [docs/api/](docs/api/README.md ## Desarrollo -Spec-driven, con [GitHub Spec Kit](https://github.com/github/spec-kit): **Spec → Plan → Tasks → Implement**. El diseño (`/specify`, `/plan`) y las tandas de implementación (`/tasks`, `/implement`) se realizan en Claude Code. Ver el **brief de diseño** en `docs/weft-design-brief.md`. +Spec-driven, con [GitHub Spec Kit](https://github.com/github/spec-kit): **Spec → Plan → Tasks → Implement**. El diseño (`/specify`, `/plan`) y las tandas de implementación (`/tasks`, `/implement`) se realizan en Claude Code. Ver el **brief de diseño** en `weft-design-brief.md`. Toolchain: Rust (con `yrs` pinneado) · .NET SDK 10 (LTS) · empaquetado nativo por RID (Linux/Windows/macOS, x64/arm64). diff --git a/native/weft-loro-ffi/include/weft_loro_ffi.h b/native/weft-loro-ffi/include/weft_loro_ffi.h index 6737f91..577a115 100644 --- a/native/weft-loro-ffi/include/weft_loro_ffi.h +++ b/native/weft-loro-ffi/include/weft_loro_ffi.h @@ -2,8 +2,11 @@ * weft_loro_ffi.h — contrato C-ABI del shim `weft-loro-ffi` (Weft, Apache-2.0). * * Réplica de la ABI de `weft-yrs-ffi` con prefijo `weft_loro_`, mapeada sobre `loro`. Fuente de - * verdad del contrato de ownership. La ABI es propia y estable: un bump de `loro` cambia lib.rs, - * jamás este header sin incrementar weft_loro_abi_version(). + * verdad del contrato de ownership. `HeaderBindingParityTests` valida que las declaraciones + * `[LibraryImport]` de Weft.Loro coinciden con este header (paridad sintáctica: conjunto de + * funciones, aridad, orden y tipos). Se mantiene A MANO: no hay csbindgen en el repo. La ABI es + * propia y estable: un bump de `loro` cambia lib.rs, jamás este header sin incrementar + * weft_loro_abi_version(). * * ── Reglas transversales (no negociables) ────────────────────────────────────────────── * 1. Panics: cada función envuelve su cuerpo en catch_unwind; un panic retorna @@ -76,9 +79,19 @@ int32_t weft_loro_native_branch_merge_probe(WeftLoroDoc* doc, const uint8_t* fie /* ── Memoria ──────────────────────────────────────────────────────────────────────────── */ void weft_loro_buf_free(uint8_t* ptr, size_t len); -/* ── Diagnóstico ──────────────────────────────────────────────────────────────────────── */ +/* ── Diagnóstico ────────────────────────────────────────────────────────────────────────── + * Versión de ESTA ABI. Hoy: v2 (v1→v2 añadió los tres probes de versionado nativo, CHARTER-10). + * El resolver del binding la verifica al cargar y rechaza un shim con versión distinta. */ uint32_t weft_loro_abi_version(void); +/* ── Test hooks (SOLO en builds con la feature de Cargo `test-hooks`) ───────────────────── + * Provoca un panic interno deliberado para verificar catch_unwind end-to-end (SC-009). + * NUNCA presente en binarios de release: el job `native` de release.yml verifica con `nm` que el + * símbolo no está exportado en los cdylibs antes de empaquetarlos. */ +#ifdef WEFT_TEST_HOOKS +int32_t weft_loro_test_panic(void); /* retorna WEFT_ERR_PANIC si el shim es correcto */ +#endif + #ifdef __cplusplus } #endif diff --git a/native/weft-yrs-ffi/include/weft_ffi.h b/native/weft-yrs-ffi/include/weft_ffi.h index 29d307d..e05a2d5 100644 --- a/native/weft-yrs-ffi/include/weft_ffi.h +++ b/native/weft-yrs-ffi/include/weft_ffi.h @@ -1,10 +1,12 @@ /* * weft_ffi.h — contrato C-ABI del shim `weft-yrs-ffi` (Weft, Apache-2.0). * - * Fuente de verdad del contrato de ownership. Un test de CI valida que las declaraciones - * `[LibraryImport]` de Weft.Core coinciden con este header (regenerables con csbindgen como - * verificación cruzada, research R1). La ABI es propia y estable: un bump de `yrs` cambia - * lib.rs, jamás este header sin incrementar weft_abi_version(). + * Fuente de verdad del contrato de ownership. `HeaderBindingParityTests` valida que las + * declaraciones `[LibraryImport]` de Weft.Core coinciden con este header (paridad sintáctica: + * conjunto de funciones, aridad, orden y tipos). Este header se mantiene A MANO: no hay csbindgen + * en el repo — la mención en research R1 era una verificación cruzada opcional que nunca se + * implementó. La ABI es propia y estable: un bump de `yrs` cambia lib.rs, jamás este header sin + * incrementar weft_abi_version(). * * ── Reglas transversales (no negociables) ────────────────────────────────────────────── * 1. Panics: cada función envuelve su cuerpo en catch_unwind; un panic retorna @@ -76,7 +78,8 @@ uint32_t weft_abi_version(void); /* ── Test hooks (SOLO en builds con la feature de Cargo `test-hooks`) ───────────────────── * Provoca un panic interno deliberado para verificar catch_unwind end-to-end (SC-009). - * NUNCA presente en binarios de release; pack-smoke (US4) verifica la ausencia del símbolo. */ + * NUNCA presente en binarios de release: el job `native` de release.yml verifica con `nm` que el + * símbolo no está exportado en los cdylibs antes de empaquetarlos. */ #ifdef WEFT_TEST_HOOKS int32_t weft_test_panic(void); /* retorna WEFT_ERR_PANIC si el shim es correcto */ #endif diff --git a/specs/001-weft-crdt-versioning/checklists/requirements.md b/specs/001-weft-crdt-versioning/checklists/requirements.md index 2aa5646..49e7392 100644 --- a/specs/001-weft-crdt-versioning/checklists/requirements.md +++ b/specs/001-weft-crdt-versioning/checklists/requirements.md @@ -42,7 +42,7 @@ Pase end-to-end de [quickstart.md](../quickstart.md) contra el árbol de `charte ### User stories -- [x] **US1 — Editar y versionar desde .NET**. `dotnet test tests/Weft.Core.Tests tests/Weft.Versioning.Tests -c Release` → 58/58 verdes. `dotnet run --project samples/Weft.Sample.Versioning` → journey completo: `Publish` v1 (`c0d1c698…`) → `Diff(v1,v2)` por palabras → `Checkout(v1)` → `Merge` convergente. +- [x] **US1 — Editar y versionar desde .NET**. `dotnet test tests/Weft.Core.Tests -c Release` (28/28) + `dotnet test tests/Weft.Versioning.Tests -c Release` (36/36) = **64 verdes**. `dotnet run --project samples/Weft.Sample.Versioning` → journey completo: `Publish` v1 (`c0d1c698…`) → `Diff(v1,v2)` por palabras → `Checkout(v1)` → `Merge` convergente. *(Corrección de CHARTER-12: el pase de CHARTER-11 anotó aquí «`dotnet test tests/A tests/B` → 58/58», pero ese comando de dos proyectos **falla con MSB1008** — se había corrido `dotnet test Weft.sln` y sumado a ojo. Es R4 de CHARTER-11 materializándose: un `[x]` con un comando que no se ejecutó. El comando del quickstart también quedó corregido.)* - [x] **US2 — Concurrencia a escala**. `--filter Category=Concurrency` → 9/9 verdes (**antes del cableado de este Charter: 0 tests**). `dotnet run --project tests/Weft.LoadTest -c Release` → `PASS`: 300 docs, 45 004 ops, 449 327 desalojos, 0 errores, managed-heap 1 MB / working-set 101 MB (`consistencia=OK memoria-acotada=OK sin-errores=OK`). - [x] **US3 — Colaboración en tiempo real**. `dotnet test tests/Weft.Server.Tests -c Release` → 70/70 verdes. Relay real arrancado (`:5199`, `FileSystemDocumentStore`) y smoke headless `npm run check` → convergencia real de 2 clientes Yjs vía `y-websocket` contra el relay: `"Hello from A. And B too."`. *Parcial*: ver «no ejecutado» abajo. - [x] **US5 — Motor reemplazable**. `dotnet test tests/Weft.Versioning.Tests -c Release` → 30/30 verdes sobre `YrsEngine` **y** `LoroEngine` (SC-008). `LoroEngine.NativeVersioning` expone los 3 probes de CHARTER-10; `YrsEngine.NativeVersioning == null` sin romper ningún flujo. diff --git a/specs/001-weft-crdt-versioning/quickstart.md b/specs/001-weft-crdt-versioning/quickstart.md index 9f9ba28..cee1f72 100644 --- a/specs/001-weft-crdt-versioning/quickstart.md +++ b/specs/001-weft-crdt-versioning/quickstart.md @@ -34,7 +34,9 @@ dotnet build Weft.sln -c Release ### US1 (P1 · M0) — Editar y versionar desde .NET ```bash -dotnet test tests/Weft.Core.Tests tests/Weft.Versioning.Tests -c Release +# `dotnet test` acepta UN proyecto por invocación (dos rutas → MSBUILD MSB1008), así que uno por línea: +dotnet test tests/Weft.Core.Tests -c Release +dotnet test tests/Weft.Versioning.Tests -c Release dotnet run --project samples/Weft.Sample.Versioning # escenario ejecutable de la historia ``` diff --git a/src/Weft.Core/Yrs/NativeMethods.cs b/src/Weft.Core/Yrs/NativeMethods.cs index 120a347..511bdb4 100644 --- a/src/Weft.Core/Yrs/NativeMethods.cs +++ b/src/Weft.Core/Yrs/NativeMethods.cs @@ -5,7 +5,9 @@ namespace Weft.Yrs; /// /// Declaraciones P/Invoke sobre la C-ABI del shim weft-yrs-ffi, generadas por el source /// generator (marshalling en compilación, sin stubs IL). -/// Deben coincidir con native/weft-yrs-ffi/include/weft_ffi.h (un test de CI lo valida). +/// Deben coincidir con native/weft-yrs-ffi/include/weft_ffi.h, que es la fuente de verdad del +/// contrato; HeaderBindingParityTests lo valida (paridad sintáctica: conjunto de funciones, +/// aridad, orden y tipos — no la semántica ni el ownership, que cubren ASan y los round-trips). /// /// /// Convenciones de marshalling: bytes de ENTRADA como (pin diff --git a/tests/Weft.Versioning.Tests/HeaderBindingParityTests.cs b/tests/Weft.Versioning.Tests/HeaderBindingParityTests.cs new file mode 100644 index 0000000..4c55ffc --- /dev/null +++ b/tests/Weft.Versioning.Tests/HeaderBindingParityTests.cs @@ -0,0 +1,348 @@ +using System.Reflection; +using System.Text; +using System.Text.RegularExpressions; +using Weft.Loro; +using Weft.Yrs; + +namespace Weft.Versioning.Tests; + +/// +/// Paridad entre el header C (fuente de verdad del contrato) y las declaraciones +/// [LibraryImport] del binding, para ambos shims (FU-017, CHARTER-12). +/// +/// +/// +/// Por qué existe. Hasta CHARTER-12, `NativeMethods.cs` y `weft_ffi.h` afirmaban ambos que +/// «un test de CI valida que las declaraciones coinciden con este header». No existía: ni +/// para yrs ni para Loro. La afirmación llevaba meses en el árbol y llegó a engañar a quien redactó +/// FU-017, que pedía «replicar para Loro el test que yrs sí tiene». Este archivo es ese test, +/// creado por primera vez, y es lo que vuelve ciertos aquellos comentarios. +/// +/// +/// Qué cubre. Que el conjunto de funciones declaradas en el header y el declarado en el +/// binding sea el mismo (ninguna sobra, ninguna falta), y que para cada una coincidan la +/// aridad, el orden y los tipos de los parámetros y el tipo de retorno, según el mapa C↔.NET de +/// — que es el contrato de marshalling que el repo ya usa de facto. +/// +/// +/// Qué NO cubre, y conviene saberlo. Es paridad sintáctica. Que `size_t` case con +/// `nuint` no prueba que el marshalling sea correcto, ni valida la semántica, la convención de +/// llamada, ni el contrato de ownership. Esas garantías siguen viniendo de ASan/LSan y de los tests +/// de round-trip. Un test cuyo alcance se exagera es exactamente la clase de fallo que CHARTER-12 +/// cierra, así que este párrafo es parte del test. +/// +/// +/// Las funciones bajo #ifdef WEFT_TEST_HOOKS se excluyen a propósito: existen solo con la +/// feature de Cargo test-hooks y el binding no las declara (PanicSafetyTests las +/// resuelve con NativeLibrary.GetExport). El gate que verifica que no viajan en release vive +/// en release.yml. +/// +/// +public sealed class HeaderBindingParityTests +{ + /// Mapa del subconjunto de C que estos headers usan → la forma .NET del binding. + private static readonly Dictionary TypeMap = new(StringComparer.Ordinal) + { + // Escalares + ["void"] = "Void", + ["int32_t"] = "Int32", + ["uint32_t"] = "UInt32", + ["uint64_t"] = "UInt64", + ["size_t"] = "UIntPtr", + // Punteros de salida: `T**` / `size_t*` → `out nint` / `out nuint` (byref). + ["uint8_t**"] = "IntPtr&", + ["size_t*"] = "UIntPtr&", + ["WeftDoc**"] = "IntPtr&", + ["WeftLoroDoc**"] = "IntPtr&", + // Handles opacos prestados (HandleLease, research R2). + ["WeftDoc*"] = "IntPtr", + ["WeftLoroDoc*"] = "IntPtr", + // Bytes de ENTRADA (const) → span con pin automático; bytes crudos (no const) → puntero. + ["const uint8_t*"] = "ReadOnlySpan", + ["uint8_t*"] = "IntPtr", + }; + + public static TheoryData Shims() => new() + { + { "yrs", "native/weft-yrs-ffi/include/weft_ffi.h", "Weft.Yrs.NativeMethods" }, + { "loro", "native/weft-loro-ffi/include/weft_loro_ffi.h", "Weft.Loro.Interop.NativeMethods" }, + }; + + [Theory] + [MemberData(nameof(Shims))] + public void Header_y_binding_declaran_las_mismas_funciones(string shim, string headerPath, string bindingType) + { + IReadOnlyDictionary header = ParseHeader(File.ReadAllText(RepoPath(headerPath))); + IReadOnlyDictionary binding = ReflectBinding(bindingType); + + string[] soloEnHeader = [.. header.Keys.Except(binding.Keys).Order()]; + string[] soloEnBinding = [.. binding.Keys.Except(header.Keys).Order()]; + + Assert.True( + soloEnHeader.Length == 0, + $"[{shim}] el header declara funciones que el binding no tiene: {string.Join(", ", soloEnHeader)}. " + + $"Si se añadió al shim, decláralas en NativeMethods; si se quitaron, bórralas del header."); + Assert.True( + soloEnBinding.Length == 0, + $"[{shim}] el binding declara funciones que el header no tiene: {string.Join(", ", soloEnBinding)}. " + + $"El header es la fuente de verdad del contrato: actualízalo."); + } + + [Theory] + [MemberData(nameof(Shims))] + public void Header_y_binding_coinciden_en_firma(string shim, string headerPath, string bindingType) + { + IReadOnlyDictionary header = ParseHeader(File.ReadAllText(RepoPath(headerPath))); + IReadOnlyDictionary binding = ReflectBinding(bindingType); + + List divergencias = []; + foreach ((string name, CFunction c) in header.OrderBy(kv => kv.Key, StringComparer.Ordinal)) + { + if (!binding.TryGetValue(name, out CFunction? net)) + { + continue; // Lo cubre Header_y_binding_declaran_las_mismas_funciones. + } + + string esperado = MapReturn(shim, name, c.ReturnType); + if (!string.Equals(esperado, net.ReturnType, StringComparison.Ordinal)) + { + divergencias.Add($"{name}: retorno header={c.ReturnType}→{esperado} vs binding={net.ReturnType}"); + } + + if (c.Parameters.Count != net.Parameters.Count) + { + divergencias.Add( + $"{name}: aridad header={c.Parameters.Count} vs binding={net.Parameters.Count}"); + continue; + } + + for (int i = 0; i < c.Parameters.Count; i++) + { + string esperadoParam = MapParam(shim, name, i, c.Parameters[i]); + if (!string.Equals(esperadoParam, net.Parameters[i], StringComparison.Ordinal)) + { + divergencias.Add( + $"{name}: param #{i} header={c.Parameters[i]}→{esperadoParam} vs binding={net.Parameters[i]}"); + } + } + } + + Assert.True( + divergencias.Count == 0, + $"[{shim}] {divergencias.Count} divergencia(s) header↔binding:{Environment.NewLine}" + + string.Join(Environment.NewLine, divergencias)); + } + + /// + /// Caso negativo: prueba que el test de arriba puede FALLAR. Un test de paridad que no sabe + /// detectar una divergencia sería la enésima verificación fantasma — el fallo que CHARTER-12 + /// cierra. Sin este caso, los dos anteriores no valen nada. + /// + [Fact] + public void El_parser_detecta_divergencias_sintéticas() + { + IReadOnlyDictionary h = ParseHeader( + """ + int32_t weft_doc_new(WeftDoc** out_doc); + int32_t weft_fantasma(WeftDoc* doc); + void weft_buf_free(uint8_t* ptr, size_t len, uint32_t sobra); + """); + + Assert.Equal(3, h.Count); + // Función que el binding real no tiene → la detecta el test de conjuntos. + Assert.Contains("weft_fantasma", h.Keys); + // Aridad divergente (3 vs 2 del binding real) → la detecta el test de firma. + Assert.Equal(3, h["weft_buf_free"].Parameters.Count); + // Y el mapa traduce lo que sí entiende. + Assert.Equal("IntPtr&", TypeMap[h["weft_doc_new"].Parameters[0]]); + } + + /// + /// El parser debe reventar ante una declaración que no entiende, nunca ignorarla en silencio: + /// ignorar es fabricar el fantasma que este archivo persigue (R1 del Charter). + /// + [Fact] + public void El_parser_falla_ruidosamente_ante_una_declaración_que_no_entiende() + { + InvalidOperationException ex = Assert.Throws( + () => ParseHeader("int32_t (*weft_callback_raro)(void);")); + + Assert.Contains("weft_", ex.Message, StringComparison.Ordinal); + } + + // ── Mapeo C → .NET ── + + private static string MapReturn(string shim, string fn, string cType) => + TypeMap.TryGetValue(cType, out string? net) + ? net + : throw new InvalidOperationException( + $"[{shim}] tipo de retorno C sin mapear en {fn}: '{cType}'. Añádelo a TypeMap con su " + + "forma .NET, no lo ignores."); + + private static string MapParam(string shim, string fn, int index, string cType) => + TypeMap.TryGetValue(cType, out string? net) + ? net + : throw new InvalidOperationException( + $"[{shim}] tipo de parámetro C sin mapear en {fn} #{index}: '{cType}'. Añádelo a TypeMap."); + + // ── Reflexión sobre el binding ── + + private static IReadOnlyDictionary ReflectBinding(string typeName) + { + Assembly asm = typeName.StartsWith("Weft.Loro", StringComparison.Ordinal) + ? typeof(LoroEngine).Assembly + : typeof(YrsEngine).Assembly; + + Type type = asm.GetType(typeName, throwOnError: true) + ?? throw new InvalidOperationException($"No se encontró el tipo {typeName}."); + + Dictionary result = new(StringComparer.Ordinal); + foreach (MethodInfo m in type.GetMethods(BindingFlags.Static | BindingFlags.NonPublic | BindingFlags.Public | BindingFlags.DeclaredOnly)) + { + // Solo la superficie del shim: el generador de [LibraryImport] emite además helpers + // internos que no forman parte del contrato. + if (!m.Name.StartsWith("weft_", StringComparison.Ordinal)) + { + continue; + } + + result[m.Name] = new CFunction( + NetTypeName(m.ReturnType), + [.. m.GetParameters().Select(p => NetTypeName(p.ParameterType))]); + } + + return result; + } + + private static string NetTypeName(Type t) + { + if (t.IsByRef) + { + return NetTypeName(t.GetElementType()!) + "&"; + } + + if (t.IsGenericType && t.GetGenericTypeDefinition() == typeof(ReadOnlySpan<>)) + { + return $"ReadOnlySpan<{NetTypeName(t.GetGenericArguments()[0])}>"; + } + + return t.Name; + } + + // ── Parser del header ── + + private sealed record CFunction(string ReturnType, IReadOnlyList Parameters); + + /// + /// Parser deliberadamente acotado al subconjunto de C que estos dos headers usan: declaraciones + /// TIPO nombre(args); de una superficie escrita a mano por nosotros. No es un parser de C + /// — no hay AST — y por eso su regla es fallar ante lo que no entiende (ver el test de arriba). + /// + private static IReadOnlyDictionary ParseHeader(string text) + { + // 1. Fuera comentarios de bloque (estos headers no usan `//`). + text = Regex.Replace(text, @"/\*.*?\*/", " ", RegexOptions.Singleline); + + // 2. Fuera las directivas de preprocesador, y fuera el bloque de test-hooks entero. + StringBuilder sb = new(); + bool enTestHooks = false; + foreach (string raw in text.Split('\n')) + { + string line = raw.Trim(); + if (line.StartsWith('#')) + { + if (line.StartsWith("#ifdef WEFT_TEST_HOOKS", StringComparison.Ordinal)) + { + enTestHooks = true; + } + else if (enTestHooks && line.StartsWith("#endif", StringComparison.Ordinal)) + { + enTestHooks = false; + } + + continue; + } + + if (!enTestHooks) + { + sb.Append(line).Append(' '); + } + } + + // 3. Una declaración por `;`. + Dictionary functions = new(StringComparer.Ordinal); + foreach (string rawStmt in sb.ToString().Split(';')) + { + string stmt = Regex.Replace(rawStmt, @"\s+", " ").Trim(); + + // Solo interesan las declaraciones del shim. Lo demás (typedef, `extern "C" {`, llaves + // sueltas) no menciona `weft_`. El filtro es a propósito más ancho que «una llamada a + // función»: cualquier declaración que mencione el prefijo tiene que ser entendida o + // reventar abajo — si exigiese `weft_…(` pegado, un puntero a función se colaría en + // silencio, que es justo el hueco que este archivo existe para impedir. + if (!Regex.IsMatch(stmt, @"\bweft_[a-z0-9_]+")) + { + continue; + } + + Match m = Regex.Match( + stmt, + @"^(?(?:const\s+)?[A-Za-z_][A-Za-z0-9_]*\s*\**)\s+(?weft_[a-z0-9_]+)\s*\((?[^()]*)\)$"); + if (!m.Success) + { + throw new InvalidOperationException( + $"El parser no entiende esta declaración del header y NO la va a ignorar: '{stmt}'. " + + "Amplía el parser o simplifica el header — ignorarla en silencio crearía un hueco " + + "de verificación (R1 de CHARTER-12)."); + } + + string name = m.Groups["name"].Value; + functions[name] = new CFunction( + Normalize(m.Groups["ret"].Value), + ParseArgs(m.Groups["args"].Value)); + } + + return functions; + } + + private static IReadOnlyList ParseArgs(string args) + { + string trimmed = Normalize(args); + if (trimmed.Length == 0 || string.Equals(trimmed, "void", StringComparison.Ordinal)) + { + return []; + } + + List types = []; + foreach (string rawArg in trimmed.Split(',')) + { + // `const uint8_t* field` → tipo `const uint8_t*`, nombre `field`. El nombre se descarta: + // el contrato es posicional, no nominal. + string arg = Normalize(rawArg); + int lastSpace = arg.LastIndexOf(' '); + types.Add(lastSpace < 0 ? arg : Normalize(arg[..lastSpace])); + } + + return types; + } + + /// Colapsa espacios y pega los `*` al tipo: `uint8_t ** out` → `uint8_t** out`. + private static string Normalize(string s) => + Regex.Replace(Regex.Replace(s, @"\s+", " "), @"\s*(\*+)\s*", "$1 ").Trim(); + + // Localiza un archivo del repo subiendo desde el binario del test (mismo patrón que + // DeterminismTests.DeterminismCorpusDir). + private static string RepoPath(string relative) + { + for (DirectoryInfo? d = new(AppContext.BaseDirectory); d is not null; d = d.Parent) + { + string candidate = Path.Combine(d.FullName, relative.Replace('/', Path.DirectorySeparatorChar)); + if (File.Exists(candidate)) + { + return candidate; + } + } + + throw new FileNotFoundException($"No se encontró '{relative}' desde el binario del test."); + } +}